Skip to main content

Lab Architecture — Operation DragonRx

Lab Metadata

Ecosystem Fit

This page mirrors the original Medium lab content into the 1200km knowledge base so it remains available inside the 1200km.com documentation ecosystem. Use the linked repository when one exists; otherwise use the deployment commands and configuration blocks preserved below as the lab source of truth.

Deployment Requirements

The full prerequisites, deployment flow, validation commands, screenshots, and operational notes are preserved from the article below. Review the repository metadata above first, then follow the article sections in order.

Part of the Operation DragonRx series · Overview · Lab Architecture · Attack Playbook · DFIR Walkthrough

Article screenshot

**A fully automated, reproducible APT41 detection lab.**Docker + Vagrant + Ansible provisioned in a single command. Three Windows VMs (Active Directory, file server, workstation), eight Linux containers (attacker tooling, Log4Shell target, Wazuh/Elastic/Zeek SIEM), and custom attack simulation tooling. Built for security researchers studying Log4Shell exploitation, Kerberoasting, DCSync, and lateral movement — with full SIEM telemetry from day one.

Estimated deploy time:~25 min (boxes cached) ·**Host requirements:**24 GB RAM, 8 cores, 120 GB SSD

Operation DragonRx series:

CTI Report

[APT41 Targeting Pharmaceutical Sector: Log4Shell to Domain Compromise Threat Intelligence Report | Operation DragonRx

Lab Architecture

[Lab Architecture — Operation DragonRx Part of the Operation DragonRx series · Overview · Lab Architecture · Attack Playbook · DFIR Walkthrough

Attack Playbook

[Attack Playbook — Operation DragonRx Phase-by-Phase Attack Guide: Exact Commands Against the Deployed Lab

Detection Guide (in progress…)

DFIR Playbook (in progress…)

Malware Analysis (in progress…)

Table of Content

  • Deployment Stack: Docker + Vagrant + Ansible

  • Deploy from Git

  • Destroy the Lab

  • Network Topology

  • Directory Structure

  • Prerequisites

  • Vagrantfile

  • docker-compose.yml

  • Ansible

  • Ansible Roles

  • Master Deploy Playbook

  • Test Playbook

  • Zeek Config

  • Host Networking Setup

  • Makefile

  • Deploy Sequence

  • VM Specifications

  • Full Lab Environment Summary

Deployment Stack: Docker + Vagrant + Ansible

All infrastructure is provisioned and configured automatically. A singlemake upcommand deploys the complete lab — Linux containers, Windows VMs, Active Directory, SIEM stack, and detection tooling — with zero manual steps.

Article screenshot

Deploy from Git

The lab is published atgithub.com/anpa1200/dragonrx-lab.

GitHub - anpa1200/dragonrx-lab *Contribute to anpa1200/dragonrx-lab development by creating an account on GitHub.*github.com

# Clone
git
clone
https://github.com/anpa1200/dragonrx-lab
cd
dragonrx-lab
# Option A — Docker containers only (no Windows VMs, no Vagrant)
# Covers Log4Shell → JNDI → Sliver C2 → Wazuh/Zeek detection
docker compose up -d
until
docker
exec
dragonrx_wazuh pgrep wazuh-analysisd >/dev/null 2>&1;
do

sleep
5;
done
docker
cp
siem/wazuh/rules/dragonrx_rules.xml dragonrx_wazuh:/var/ossec/etc/rules/
docker
exec
dragonrx_wazuh /var/ossec/bin/wazuh-control restart
# → Kibana: http://localhost:5601
# Option B — Full lab (Docker + Windows VMs + Ansible)
bash scripts/deploy.sh
# one script, ~45 min
# or: make up

> Zeek note: Zeek runs with network_mode: host (no fixed IP). Logs live inside the dragonrx_zeek container: docker exec dragonrx_zeek ls /usr/local/zeek/logs/current/

One-Script Deployment

For environments withoutmake, or when a self-contained deploy is needed:

#
Full deployment (identical to
'make up'
+
'make test'
)
bash scripts/deploy.sh
#
Skip
Windows VMs (reuse already
-
running
VMs)
bash scripts
/
deploy.sh
--skip-vms
# Skip Ansible reprovisioning (reuse already-provisioned state)
bash scripts/deploy.sh --skip-vms --skip-ansible
# Deploy
without

running
the
final
test suite
bash scripts
/
deploy.sh
--no-test

**deploy.sh**performs all 7 steps in sequence:

Article screenshot

Prints a formatted access summary on completion with elapsed time.

Article screenshot

Destroy the Lab

# Option A — Docker only
docker compose down -v
# stops containers and removes volumes
# Option B - Full lab (Docker + VMs)
bash scripts/destroy.sh
# tears down VMs via Vagrant, then docker compose down -v
# or: make down

> Volumes hold Wazuh indices and Zeek logs. -v removes them; omit it if you want to preserve data between runs.

Network Topology

Article screenshot

**Networking note:**VirtualBox VMs bridge NIC2 directly onto the Dockertarget_netbridge (br-xxxxxxxx) using Vagrant'spublic_networkdriver. No VirtualBox host-only adapter (vboxnet0) is needed.scripts/setup_routing.shenables IP forwarding and adds iptables FORWARD rules between the two Docker bridges (attacker_nettarget_net), and disables TX checksum offloading on the target bridge so Windows VMs accept TCP packets from Docker containers.

Directory Structure

dragonrx-lab/
├── Makefile
# make up / test / attack / shell / down / reset
├── Vagrantfile
# DC01 (WS2019), FS01 (WS2019), WS01 (Win10)
├── docker-compose.yml
# 8 Linux containers, two named subnets

├── scripts/
│ ├── deploy.sh
# ★ ONE-SCRIPT full deployment (see below)
│ ├── setup_routing.sh
# host iptables bridge: Docker ↔ VirtualBox
│ └── fix_vboxdrv.sh
# rebuild VBoxDRV DKMS module if needed

├── ansible/
│ ├── ansible.cfg
# interpreter, timeout, retry config
│ ├── requirements.yml
# Galaxy: ansible.windows, microsoft.ad, community.*
│ ├── inventory/
│ │ ├── hosts.ini
# DC01/FS01/WS01 WinRM addresses
│ │ └── group_vars/
│ │ ├── all.yml
# domain, passwords, Wazuh version
│ │ └── windows.yml
# WinRM connection vars for all Windows hosts
│ ├── playbooks/
│ │ ├── deploy.yml
# master playbook: 6 phases in order
│ │ └── test.yml
# smoke tests + detection validation
│ └── roles/
│ ├── dc01/tasks/main.yml
# AD DS install → forest promote → users → SPNs → audit SACL
│ ├── fs01/tasks/main.yml
# domain join → SMB shares → 45 crown-jewel data files
│ ├── ws01/tasks/main.yml
# domain join → jsmith local admin → C:\Temp
│ └── wazuh_agent/tasks/main.yml
# download MSI → install → enroll with manager

├── jndi/
│ ├── Dockerfile.jndi
# eclipse-temurin:11-jdk-jammy + Maven-built marshalsec
│ ├── start.sh
# launch HTTP payload server + marshalsec LDAP relay
│ └── payloads/
# Exploit.class served to Log4Shell victims

├── attacker/
│ ├── Dockerfile.kali
# kalilinux/kali-rolling base + nmap, impacket, hashcat,
│ ├── tools/
# volume-mounted into Kali container at /opt/tools
│ └── loot/
# exfil landing zone at /opt/loot

├── c2/
│ ├── Dockerfile.sliver
# Sliver v1.7.3 binary from GitHub releases
│ ├── configs/
# Sliver operator config (auto-generated)
│ └── loot/
# Sliver download landing zone

├── siem/
│ ├── wazuh/
│ │ ├── ossec.conf
# Wazuh 4.7.0 default manager config (reference copy)
│ │ └── rules/
│ │ └── dragonrx_rules.xml
# 8 custom rules: 100110-100170
│ │
# (installed via docker cp post-startup)
│ └── zeek/
│ ├── entrypoint.sh
# ★ Bridge detection + Zeek startup (-C flag, /proc/net/route)
│ └── local.zeek
# Log4Shell JNDI header + long-label DNS detection

Prerequisites

# Hardware minimum
CPU: 8 cores (VT-x/AMD-V required)
RAM: 32 GB (Docker ~10 GB + 3 Windows VMs ~18 GB + headroom)
Disk: 250 GB SSD
# Software - install before running make up
docker >= 24.0
docker compose >= 2.20
vagrant >= 2.4.0
virtualbox >= 7.0
ansible >= 2.16
python3 >= 3.10
# Ansible collections + Python deps
pip3 install pywinrm requests
ansible-galaxy collection install -r ansible/requirements.yml
# requirements.yml covers:
# ansible.windows, community.windows, microsoft.ad,
# community.docker, community.general
# Vagrant plugins
vagrant plugin install vagrant-reload
# post-domain-join reboots
vagrant plugin install vagrant-hostmanager
# /etc/hosts management

Vagrantfile

# -*- mode: ruby -*-
# vi: set ft=ruby :
VAGRANTFILE_API_VERSION
=
"2"
WINDOWS_BOXES
= {

"DC01"
=> {
box:

"StefanScherer/windows_2019"
,
ip:

"192.168.10.10"
,
memory:

4096
,
cpus:

2
},

"FS01"
=> {
box:

"StefanScherer/windows_2019"
,
ip:

"192.168.10.20"
,
memory:

4096
,
cpus:

2
},

"WS01"
=> {
box:

"StefanScherer/windows_10"
,
ip:

"192.168.10.50"
,
memory:

4096
,
cpus:

2
},
}
def

find_docker_bridge

%w[target_net dragonrx]
.each
do
|
filter
|
id =
`docker network ls --filter name=
#{filter}
--format "{{.ID}}" 2>/dev/null`
.strip.split.first

return

"br-
#{id[
0
,
12
]}
"

if
id && !id.empty?

end

nil
end
DOCKER_BRIDGE
= find_docker_bridge
Vagrant
.configure(
VAGRANTFILE_API_VERSION
)
do
|
config
|
config.vagrant.plugins = [
"vagrant-reload"
,
"vagrant-hostmanager"
]

# Ignore the box's packed Vagrantfile — it declares a synced folder and

# runs a $username provisioner that fails on newer WinRM/Ruby. We set

# all WinRM, network, and provisioner config ourselves.
config.vm.ignore_box_vagrantfile =
true
config.hostmanager.enabled =
true
config.hostmanager.manage_host =
false
config.hostmanager.manage_guest =
false

# Ansible sets hostnames + /etc/hosts

WINDOWS_BOXES
.each
do
|
name, cfg
|
config.vm.define name
do
|
node
|
node.vm.box = cfg[
:box
]

# No vm.hostname — triggers mid-boot Windows reboot that stalls 15+ min

# on the bridged NIC (no DHCP on Docker bridge). Ansible handles it.
node.vm.synced_folder
"."
,
"/vagrant"
,
disabled:

true
node.vm.communicator =
"winrm"
node.winrm.username =
"vagrant"
node.winrm.password =
"vagrant"
node.winrm.transport =
:negotiate
node.winrm.basic_auth_only =
false
node.winrm.retry_limit =
20
node.winrm.retry_delay =
10

# NIC 1: NAT (internet / WinRM port forwarding)

# NIC 2: bridged to Docker target_net bridge — auto_config false so

# Vagrant doesn't trigger a reboot trying to set the IP via WinRM.

# Static IP is set by the PowerShell provisioner below instead.
abort
"Docker target_net bridge not found — run 'docker compose up -d' first."

unless

DOCKER_BRIDGE
node.vm.network
"public_network"
,

bridge:

DOCKER_BRIDGE
,

auto_config:

false
node.vm.provider
"virtualbox"

do
|
vb
|
vb.name =
"dragonrx_
#{name.downcase}
"
vb.memory = cfg[
:memory
]
vb.cpus = cfg[
:cpus
]
vb.gui =
false
vb.customize [
"modifyvm"
,
:id
,
"--nested-hw-virt"
,
"on"
]
vb.customize [
"modifyvm"
,
:id
,
"--clipboard"
,
"bidirectional"
]

end

# Set static IP on NIC2 (bridged adapter) without triggering a reboot.

# Finds NIC2 by excluding the default-route adapter (NIC1 = NAT).
node.vm.provision
"shell"
,
privileged:

false
,

powershell_elevated_interactive:

false
,

inline:

<<~PS
$defIdx = (Get-NetRoute -DestinationPrefix '0.0.0.0/0' |
Sort-Object RouteMetric | Select-Object -First 1).InterfaceIndex
$nic2 = Get-NetAdapter |
Where-Object { $_.InterfaceIndex -ne $defIdx -and $_.Status -eq 'Up' } |
Select-Object -First 1
if ($nic2) {
Remove-NetIPAddress -InterfaceIndex $nic2.InterfaceIndex -Confirm:$false -ErrorAction SilentlyContinue
Remove-NetRoute -InterfaceIndex $nic2.InterfaceIndex -Confirm:$false -ErrorAction SilentlyContinue
New-NetIPAddress -InterfaceIndex $nic2.InterfaceIndex `
-IPAddress '
#{cfg[
:ip
]}
' -PrefixLength 24
Write-Host "NIC2 configured:
#{cfg[
:ip
]}
/24"
} else {
Write-Host "WARNING: NIC2 not found (may still be initialising)"
}
Write-Host "VM
#{name}
ready for Ansible"
PS

end

end
end

Vagrant boxes used:

  • StefanScherer/windows_2019— Windows Server 2019 Datacenter, WinRM pre-enabled, ~9 GB download

  • StefanScherer/windows_10— Windows 10 22H2, ~8 GB download

  • Firstvagrant updownloads boxes; subsequent runs use local cache

> NIC2 bridging note: VMs use public_network (not private_network ) so VirtualBox bridges the NIC directly onto the Docker target_net Linux bridge via AF_PACKET. auto_config: false prevents Vagrant from attempting DHCP (there is none on the Docker bridge) or issuing a WinRM reboot mid-boot. The PowerShell provisioner assigns the static IP immediately after boot. No vboxnet0 host-only adapter is used or required.

docker-compose.yml

networks:

attacker_net:

driver:

bridge

ipam:

config:

-

subnet:

10.0
.0
.0
/24

target_net:

driver:

bridge

ipam:

config:

-

subnet:

192.168
.10
.0
/24

gateway:

192.168
.10
.254
services:

# ── ATTACKER SIDE ───────────────────────────────────────────────────

rxphage_builder:

build:

context:

./attacker

dockerfile:

Dockerfile.rxphage

container_name:

dragonrx_rxphage_builder

volumes:

-

./attacker/tools:/output

kali:

image:

kalilinux/kali-rolling:latest

container_name:

dragonrx_kali

hostname:

kali

networks:

attacker_net:

ipv4_address:

10.0
.0
.5

target_net:

ipv4_address:

192.168
.10
.5

tty:

true

stdin_open:

true

volumes:

-

./attacker/tools:/opt/tools

-

./attacker/loot:/opt/loot

cap_add:
[
NET_ADMIN
,
NET_RAW
]

sysctls:

net.ipv4.ip_forward:

1

depends_on:

rxphage_builder:

condition:

service_completed_successfully

sliver_c2:

build:

context:

./c2

dockerfile:

Dockerfile.sliver

image:

dragonrx_sliver:local

container_name:

dragonrx_c2

hostname:

c2

networks:

attacker_net:

ipv4_address:

10.0
.0
.10

ports:

-

"31337:31337"

volumes:

-

./c2/configs:/root/.sliver

-

./c2/loot:/opt/loot

restart:

unless-stopped

jndi_server:

build:

context:

./jndi

dockerfile:

Dockerfile.jndi

container_name:

dragonrx_jndi

hostname:

jndi

networks:

attacker_net:

ipv4_address:

10.0
.0
.20

ports:

-

"1389:1389"

-

"8888:8080"

# ── TARGET SIDE ─────────────────────────────────────────────────────

web01:

image:

ghcr.io/christophetd/log4shell-vulnerable-app:latest

container_name:

dragonrx_web01

hostname:

web01

networks:

attacker_net:

ipv4_address:

10.0
.0
.100

target_net:

ipv4_address:

192.168
.10
.100

ports:

-

"8080:8080"

environment:

-

DOMAIN_CONTROLLER=192.168.10.10

-

LDAP_USER=svc_ldap

-

LDAP_PASS=NovaTech2021!

healthcheck:

test:
[
"CMD-SHELL"
,
"wget -qO- --header='X-Api-Version: health' http://localhost:8080/ >/dev/null 2>&1 || exit 1"
]

interval:

15s

timeout:

5s

retries:

5

# ── SOC / DETECTION SIDE ────────────────────────────────────────────

wazuh:

image:

wazuh/wazuh-manager:4.7.0

container_name:

dragonrx_wazuh

hostname:

wazuh

networks:

target_net:

ipv4_address:

192.168
.10
.200

ports:

-

"1514:1514/tcp"

-

"1514:1514/udp"

-

"1515:1515"

-

"55000:55000"

volumes:

-

wazuh_data:/var/ossec/data

healthcheck:

test:
[
"CMD-SHELL"
,
"pgrep wazuh-analysisd > /dev/null && pgrep wazuh-remoted > /dev/null"
]

interval:

20s

retries:

5

elasticsearch:

image:

docker.elastic.co/elasticsearch/elasticsearch:8.11.0

container_name:

dragonrx_elastic

hostname:

elastic

networks:

target_net:

ipv4_address:

192.168
.10
.202

environment:

-

discovery.type=single-node

-

xpack.security.enabled=false

-

ES_JAVA_OPTS=-Xms4g

-Xmx4g

volumes:

-

elastic_data:/usr/share/elasticsearch/data

healthcheck:

test:
[
"CMD-SHELL"
,
"curl -sf http://localhost:9200/_cluster/health | grep -v red"
]

interval:

20s

retries:

10

kibana:

image:

docker.elastic.co/kibana/kibana:8.11.0

container_name:

dragonrx_kibana

hostname:

kibana

networks:

target_net:

ipv4_address:

192.168
.10
.203

ports:

-

"5601:5601"

environment:

-

ELASTICSEARCH_HOSTS=http://192.168.10.202:9200

depends_on:

elasticsearch:

condition:

service_healthy

zeek:

image:

zeek/zeek:6.2.1

container_name:

dragonrx_zeek

hostname:

zeek

network_mode:

host

volumes:

-

./siem/zeek/local.zeek:/usr/local/zeek/share/zeek/site/local.zeek:ro

-

./siem/zeek/entrypoint.sh:/entrypoint.sh:ro

-

zeek_logs:/usr/local/zeek/logs

cap_add:
[
NET_ADMIN
,
NET_RAW
]

command:
[
"/bin/sh"
,
"/entrypoint.sh"
]
volumes:

wazuh_data:

elastic_data:

zeek_logs:

Ansible

ansible/requirements.yml

collections:

-

name:

ansible.windows

version:

">=2.3.0"

-

name:

community.windows

version:

">=2.2.0"

-

name:

microsoft.ad

version:

">=1.5.0"

-

name:

community.docker

version:

">=3.6.0"

-

name:

community.general

version:

">=8.0.0"

ansible/ansible.cfg

[defaults]
inventory
= inventory/hosts.ini
roles_path
= roles
stdout_callback
= yaml
timeout
=
60
host_key_checking
=
False
[winrm]
transport
= basic
server_cert_validation
= ignore

ansible/inventory/hosts.ini

[docker_linux]
web01 ansible_host=192.168.10.100 ansible_connection=docker ansible_docker_extra_args=
"-u root"
ansible_container=dragonrx_web01
kali ansible_host=10.0.0.5 ansible_connection=docker ansible_container=dragonrx_kali
[windows]
dc01 ansible_host=192.168.10.10
fs01 ansible_host=192.168.10.20
ws01 ansible_host=192.168.10.50
[all:vars]
domain_name=novatech.local
domain_netbios=NOVATECH
domain_controller_ip=192.168.10.10

ansible/inventory/group_vars/windows.yml

ansible_user:
vagrant
ansible_password:
vagrant
ansible_connection:
winrm
ansible_winrm_transport:
basic
ansible_winrm_server_cert_validation:
ignore
ansible_winrm_port:

5985
ansible_winrm_read_timeout_sec:

120
ansible_winrm_operation_timeout_sec:

90
ansible_become:

false
domain_admin_user:
Administrator
domain_admin_password:

"NovaTech_Admin2024!"
safe_mode_password:

"DragonRx2024!"

> WinRM transport note: basic is required. pywinrm 0.4.x lists ntlm (not negotiate ) in its supported auth types, and OpenSSL 3.x removed the MD4 hash that NTLM depends on. basic over plain HTTP port 5985 works reliably with the StefanScherer Vagrant boxes.

ansible/inventory/group_vars/all.yml

lab_users:

-

name:

jsmith

password:

"Research#2024"

department:

"R&D"

local_admin_on:

ws01

-

name:

svc_ldap

password:

"NovaTech2021!"

description:

"LDAP service account — creds leaked in context.xml"

password_never_expires:

true

-

name:

svc_backup

password:

"Backup_Svc99!"

description:

"Kerberoastable backup service account"

password_never_expires:

true

spn:

"MSSQLSvc/fs01.novatech.local:1433"

member_of:

"Backup Operators"
# VMs cannot reach the Wazuh container IP (192.168.10.200) directly due to
# VirtualBox bridge FDB behavior. Use the host bridge IP (192.168.10.254)
# instead — docker-proxy forwards TCP 1514/1515 to the container.
wazuh_manager_ip:

192.168
.10
.254
sysmon_version:

"15.0"
sysmon_sha256:

""

> Wazuh manager IP note: VirtualBox bridges NICs via AF_PACKET sockets. The Linux bridge FDB never learns VM MACs from AF_PACKET injections, so SYN-ACKs from the Wazuh container (192.168.10.200) are flooded to bridge ports but never delivered back to the VM’s AF_PACKET socket — producing Zeek S1/Sh state (SYN sent, no ACK). Using the host bridge IP (192.168.10.254) routes Wazuh agent traffic through docker-proxy , which handles the TCP return path through the host TCP stack correctly.

Ansible Roles

roles/dc01/tasks/main.yml

---
-

name:

Install

AD

DS

role

ansible.windows.win_feature:

name:

AD-Domain-Services

include_management_tools:

true

state:

present

register:

ad_feature
-

name:

Reboot

after

AD

DS

install

ansible.windows.win_reboot:

reboot_timeout:

300

post_reboot_delay:

30

when:

ad_feature.reboot_required
-

name:

Set

local

Administrator

password

(required

before

DC

promotion)

ansible.windows.win_user:

name:

Administrator

password:

"
{{ domain_admin_password }}
"

state:

present
-

name:

Promote

to

Domain

Controller

microsoft.ad.domain:

dns_domain_name:

"
{{ domain_name }}
"

domain_netbios_name:

"
{{ domain_netbios }}
"

safe_mode_password:

"
{{ safe_mode_password }}
"

reboot:

false

register:

domain_result
-

name:

Reboot

after

domain

promotion

ansible.windows.win_reboot:

reboot_timeout:

1200

post_reboot_delay:

120

connect_timeout:

60

when:

domain_result.changed

or

domain_result.reboot_required

|

default(false)
-

name:

Wait

for

AD

web

services

ansible.windows.win_wait_for:

port:

389

host:

127.0
.0
.1

timeout:

120
-

name:

Create

domain

users

microsoft.ad.user:

name:

"
{{ item.name }}
"

sam_account_name:

"
{{ item.name }}
"

password:

"
{{ item.password }}
"

password_never_expires:

"
{{ item.password_never_expires | default(false) }}
"

description:

"
{{ item.description | default(omit) }}
"

enabled:

true

state:

present

loop:

"
{{ lab_users }}
"
-

name:

Set

SPN

on

svc_backup

(Kerberoastable)

ansible.windows.win_powershell:

script:

|
$spn = "{{ item.spn }}"
$acct = "{{ domain_netbios }}\{{ item.name }}"
$existing = & setspn -L "{{ item.name }}" 2>&1
if ($existing -notmatch [regex]::Escape($spn)) {
& setspn -S $spn $acct
Write-Output "SPN set: $spn on $acct"
} else {
Write-Output "SPN already present: $spn"
}

loop:

"
{{ lab_users | selectattr('spn', 'defined') | list }}
"
-

name:

Add

svc_backup

to

Backup

Operators

ansible.windows.win_powershell:

script:

|
$members = (Get-ADGroupMember "Backup Operators").SamAccountName
if ($members -notcontains "svc_backup") {
Add-ADGroupMember -Identity "Backup Operators" -Members "svc_backup"
Write-Output "Added svc_backup to Backup Operators"
} else {
Write-Output "svc_backup already in Backup Operators"
}
-

name:

Enable

Directory

Service

Access

auditing

(required

for

DCSync

EID

4662
)

ansible.windows.win_command:

>
auditpol /set /subcategory:"Directory Service Access" /success:enable /failure:enable
-

name:

Set

SACL

on

domain

NC

for

DCSync

detection

ansible.windows.win_powershell:

script:

|
$rootDSE = [ADSI]"LDAP://RootDSE"
$defaultNC = $rootDSE.defaultNamingContext
$acl = (Get-Acl "AD:\$defaultNC")
$identity = [System.Security.Principal.NTAccount]"Everyone"
$adRights = [System.DirectoryServices.ActiveDirectoryRights]"ExtendedRight"
$type = [System.Security.AccessControl.AccessControlType]"Success"
$inherit = [System.DirectoryServices.ActiveDirectorySecurityInheritance]"All"
$guidAll = [guid]"1131f6ad-9c07-11d1-f79f-00c04fc2dcd2"
$ace = New-Object System.DirectoryServices.ActiveDirectoryAuditRule(
$identity, $adRights, $type, $guidAll, $inherit)
$acl.AddAuditRule($ace)
Set-Acl "AD:\$defaultNC" $acl
Write-Output "SACL configured for DCSync detection (EID 4662)"
-

name:

Set

DNS

forwarder

ansible.windows.win_powershell:

script:

|

Set-DnsServerForwarder

-IPAddress

"8.8.8.8"

-PassThru

> DC promotion notes:

  • Therebootflag is set tofalseso Ansible controls the reboot window.reboot_timeout: 1200is needed — DC promotion + AD DS initialization takes several minutes post-reboot.

  • microsoft.ad.domaindoes not accept astateparameter; idempotency is handled by the module internally.

  • The local Administrator password must be set beforeDCPromoor promotion exits with code 94 (blank password rejected).

roles/fs01/tasks/main.yml

---
-

name:

Wait

for

DC01

LDAP

ansible.windows.win_wait_for:

host:

"
{{ domain_controller_ip }}
"

port:

389

timeout:

300
-

name:

Set

DNS

to

DC01

on

target_net

NIC

(required

for

domain

join)

ansible.windows.win_powershell:

script:

|
$defIdx = (Get-NetRoute -DestinationPrefix '0.0.0.0/0' |
Sort-Object RouteMetric | Select-Object -First 1).InterfaceIndex
$nic2 = Get-NetAdapter |
Where-Object { $_.InterfaceIndex -ne $defIdx -and $_.Status -eq 'Up' } |
Select-Object -First 1
if ($nic2) {
Set-DnsClientServerAddress -InterfaceIndex $nic2.InterfaceIndex `
-ServerAddresses "{{ domain_controller_ip }}"
Write-Output "DNS set to {{ domain_controller_ip }} on $($nic2.Name)"
} else {
throw "NIC2 not found — cannot set DNS"
}
-

name:

Join

domain

microsoft.ad.membership:

dns_domain_name:

"
{{ domain_name }}
"

domain_admin_user:

"
{{ domain_netbios }}
\\
{{ domain_admin_user }}
"

domain_admin_password:

"
{{ domain_admin_password }}
"

state:

domain

register:

domain_join
-

name:

Reboot

after

domain

join

ansible.windows.win_reboot:

reboot_timeout:

300

when:

domain_join.changed
-

name:

Create

crown

jewel

directories

ansible.windows.win_file:

path:

"
{{ item }}
"

state:

directory

loop:

-

C:\Research

-

C:\Manufacturing

-

C:\SYSVOL_backup
-

name:

Generate

dummy

Phase

III

trial

data

ansible.windows.win_powershell:

script:

|
1..30 | ForEach-Object {
$content = "Phase III Antiviral Trial Data - Subject ID: $_ - Visit: $(Get-Date -Format yyyy-MM-dd)"
$content | Out-File "C:\Research\clinical_data_record_$_.csv" -Encoding UTF8
}
1..15 | ForEach-Object {
$content = "Proprietary Synthesis Process v2.3 - Batch $_ - CONFIDENTIAL"
$content | Out-File "C:\Manufacturing\synthesis_process_$_.docx" -Encoding UTF8
}
"NovaTech Phase III NDA Filing 2026 - RESTRICTED" | Out-File "C:\Research\NDA_filing_2026.pdf" -Encoding UTF8
Write-Output "Crown jewel data created"
-

name:

Create

SMB

shares

ansible.windows.win_share:

name:

"
{{ item.name }}
"

path:

"
{{ item.path }}
"

full:

"
{{ item.full }}
"

read:

"
{{ item.read }}
"

state:

present

loop:

-

name:

Research

path:

C:\Research

full:

"NOVATECH\\jsmith"

read:

"NOVATECH\\Domain Users"

-

name:

Manufacturing

path:

C:\Manufacturing

full:

"NOVATECH\\Administrator"

read:

"NOVATECH\\jsmith"

> DNS pre-join note: NIC2 (the host-only 192.168.10.0/24 adapter) must resolve novatech.local before the domain join module runs. Setting DNS to DC01 on NIC2 before calling microsoft.ad.membership is required — without it the join fails with "domain does not exist or could not be contacted."

roles/ws01/tasks/main.yml

---
-

name:

Wait

for

DC01

LDAP

ansible.windows.win_wait_for:

host:

"
{{ domain_controller_ip }}
"

port:

389

timeout:

300
-

name:

Set

DNS

to

DC01

on

target_net

NIC

(required

for

domain

join)

ansible.windows.win_powershell:

script:

|
$defIdx = (Get-NetRoute -DestinationPrefix '0.0.0.0/0' |
Sort-Object RouteMetric | Select-Object -First 1).InterfaceIndex
$nic2 = Get-NetAdapter |
Where-Object { $_.InterfaceIndex -ne $defIdx -and $_.Status -eq 'Up' } |
Select-Object -First 1
if ($nic2) {
Set-DnsClientServerAddress -InterfaceIndex $nic2.InterfaceIndex `
-ServerAddresses "{{ domain_controller_ip }}"
Write-Output "DNS set to {{ domain_controller_ip }} on $($nic2.Name)"
} else {
throw "NIC2 not found — cannot set DNS"
}
-

name:

Join

domain

microsoft.ad.membership:

dns_domain_name:

"
{{ domain_name }}
"

domain_admin_user:

"
{{ domain_netbios }}
\\
{{ domain_admin_user }}
"

domain_admin_password:

"
{{ domain_admin_password }}
"

state:

domain

register:

domain_join
-

name:

Reboot

after

domain

join

ansible.windows.win_reboot:

reboot_timeout:

300

when:

domain_join.changed
-

name:

Enable

SMB

and

set

target_net

NIC

to

Private

profile

ansible.windows.win_powershell:

script:

|
Enable-NetFirewallRule -DisplayGroup "File and Printer Sharing"
$defIdx = (Get-NetRoute -DestinationPrefix '0.0.0.0/0' |
Sort-Object RouteMetric | Select-Object -First 1).InterfaceIndex
$nic2 = Get-NetAdapter |
Where-Object { $_.InterfaceIndex -ne $defIdx -and $_.Status -eq 'Up' } |
Select-Object -First 1
if ($nic2) {
Set-NetConnectionProfile -InterfaceIndex $nic2.InterfaceIndex -NetworkCategory Private -ErrorAction SilentlyContinue
}
-

name:

Add

jsmith

as

local

administrator

ansible.windows.win_group_membership:

name:

Administrators

members:
[
"NOVATECH\\jsmith"
]

state:

present
-

name:

Create

C:\Temp

for

staging

artifacts

ansible.windows.win_file:

path:

C:\Temp

state: directorySMB firewall note:

Windows

10

disables

File

and

Printer

Sharing

by

default,

and

the

Docker

bridge

NIC

gets

a

“Public”

network

profile

which

blocks

SMB.

Enable-NetFirewallRule

opens

the

rule

for

all

profiles;

Set-NetConnectionProfile

downgrades

the

lab

NIC

to

Private

so

Windows

Security

Center

stops

complaining.

roles/wazuh_agent/tasks/main.yml

---
# Remove any stale agent record for this hostname so re-enrollment isn't blocked
# by Wazuh's "not disconnected long enough" guard after container recreation.
-

name:

Remove

stale

Wazuh

agent

record

(idempotent

pre-enroll

cleanup)

delegate_to:

localhost

ansible.builtin.shell:

|
TOKEN=$(curl -sk -u wazuh:wazuh -X POST \
https://127.0.0.1:55000/security/user/authenticate \
| python3 -c "import sys,json; print(json.load(sys.stdin)['data']['token'])")
ID=$(curl -sk -H "Authorization: Bearer $TOKEN" \
"https://127.0.0.1:55000/agents?name={{ inventory_hostname }}" \
| python3 -c "import sys,json; items=json.load(sys.stdin)['data']['affected_items']; print(items[0]['id'] if items else '')")
if [ -n "$ID" ]; then
curl -sk -X DELETE -H "Authorization: Bearer $TOKEN" \
"https://127.0.0.1:55000/agents?agents_list=$ID&status=all&older_than=0s" >/dev/null
echo "Removed stale agent $ID for {{ inventory_hostname }}"
else
echo "No stale agent record for {{ inventory_hostname }}"
fi

changed_when:

false
-

name:

Ensure

C:\Temp

exists

ansible.windows.win_file:

path:

C:\Temp

state:

directory
-

name:

Download

Wazuh

agent

MSI

ansible.windows.win_get_url:

url:

"https://packages.wazuh.com/4.x/windows/wazuh-agent-4.7.0-1.msi"

dest:

C:\Temp\wazuh-agent.msi
-

name:

Install

Wazuh

agent

ansible.windows.win_package:

path:

C:\Temp\wazuh-agent.msi

product_id:

"{54E68B4D-3C4E-4D9C-8E65-C4A62C6B9E7A}"

arguments:

>-
WAZUH_MANAGER="{{ wazuh_manager_ip }}"
WAZUH_REGISTRATION_SERVER="{{ wazuh_manager_ip }}"
WAZUH_AGENT_NAME="{{ inventory_hostname }}"

state:

present
-

name:

Start

and

enable

Wazuh

agent

ansible.windows.win_service:

name:

WazuhSvc

state:

started

start_mode:

auto

> Stale agent cleanup note: After docker compose down -v and up , Wazuh starts fresh with no agent records. But if the manager container is recreated while VMs are still enrolled, Wazuh rejects re-registration with "Duplicate name — not disconnected long enough" (default agents_disconnection_time is 10 min). The pre-enroll API DELETE clears the record immediately so re-deploy works without a 10-minute wait.

Master Deploy Playbook

ansible/playbooks/deploy.yml

---
# Operation DragonRx — Full Lab Deploy
# Usage: ansible-playbook ansible/playbooks/deploy.yml
# Run after: docker compose up -d && vagrant up
-

name:

"Phase 1 — Verify Docker services healthy"

hosts:

localhost

gather_facts:

false

tasks:

-

name:

Wait

for

Wazuh

manager

API

ansible.builtin.wait_for:

host:

127.0
.0
.1

port:

55000

timeout:

120

-

name:

Wait

for

Kibana

(implicitly

confirms

Elasticsearch)

ansible.builtin.uri:

url:

http://localhost:5601/api/status

status_code:

200

register:

kibana_health

until:

kibana_health.status

==

200

retries:

30

delay:

10
-

name:

"Phase 2 — Configure DC01 (Active Directory)"

hosts:

dc01

gather_facts:

true

pre_tasks:

-

name:

Set

computer

name

to

DC01

ansible.windows.win_hostname:

name:

DC01

register:

dc01_name

-

name:

Reboot

if

hostname

changed

ansible.windows.win_reboot:

reboot_timeout:

300

post_reboot_delay:

30

when:

dc01_name.reboot_required

roles:

-

dc01

-

wazuh_agent
-

name:

"Phase 3 — Configure FS01 (File Server)"

hosts:

fs01

gather_facts:

true

pre_tasks:

-

name:

Set

computer

name

to

FS01

ansible.windows.win_hostname:

name:

FS01

register:

fs01_name

-

name:

Reboot

if

hostname

changed

ansible.windows.win_reboot:

reboot_timeout:

300

post_reboot_delay:

30

when:

fs01_name.reboot_required

roles:

-

fs01

-

wazuh_agent
-

name:

"Phase 4 — Configure WS01 (Workstation)"

hosts:

ws01

gather_facts:

true

pre_tasks:

-

name:

Set

computer

name

to

WS01

ansible.windows.win_hostname:

name:

WS01

register:

ws01_name

-

name:

Reboot

if

hostname

changed

ansible.windows.win_reboot:

reboot_timeout:

300

post_reboot_delay:

30

when:

ws01_name.reboot_required

roles:

-

ws01

-

wazuh_agent
-

name:

"Phase 5 — Configure SIEM indexes"

hosts:

localhost

gather_facts:

false

tasks:

-

name:

Create

Kibana

index

pattern

for

Wazuh

ansible.builtin.uri:

url:

http://localhost:5601/api/saved_objects/index-pattern

method:

POST

headers:

Content-Type:

application/json

kbn-xsrf:

"true"

body_format:

json

body:

attributes:

title:

"wazuh-alerts-*"

timeFieldName:

"@timestamp"

status_code:
[
200
,
409
]

-

name:

Create

Kibana

index

pattern

for

Zeek

ansible.builtin.uri:

url:

http://localhost:5601/api/saved_objects/index-pattern

method:

POST

headers:

Content-Type:

application/json

kbn-xsrf:

"true"

body_format:

json

body:

attributes:

title:

"zeek-*"

timeFieldName:

"@timestamp"

status_code:
[
200
,
409
]
-

name:

"Phase 6 — Run smoke tests"

import_playbook:

test.yml

Test Playbook

ansible/playbooks/test.yml

---
# Smoke tests + detection validation
# Usage: ansible-playbook ansible/playbooks/test.yml
-

name:

"Test 1 — Network connectivity"

hosts:

localhost

gather_facts:

false

vars:

connectivity_checks:

-
{
host:

127.0
.0
.1
,
port:

8080
,
name:

"WEB01 patient portal"
}

-
{
host:

192.168
.10
.10
,
port:

389
,
name:

"DC01 LDAP"
}

-
{
host:

192.168
.10
.10
,
port:

445
,
name:

"DC01 SMB"
}

-
{
host:

192.168
.10
.20
,
port:

445
,
name:

"FS01 SMB"
}

-
{
host:

192.168
.10
.50
,
port:

445
,
name:

"WS01 SMB"
}

-
{
host:

127.0
.0
.1
,
port:

1515
,
name:

"Wazuh enrollment"
}

-
{
host:

192.168
.10
.202
,
port:

9200
,
name:

"Elasticsearch"
}

-
{
host:

127.0
.0
.1
,
port:

5601
,
name:

"Kibana"
}

-
{
host:

127.0
.0
.1
,
port:

1389
,
name:

"JNDI LDAP server"
}

-
{
host:

127.0
.0
.1
,
port:

8888
,
name:

"JNDI payload server"
}

tasks:

-

name:

Check

TCP

connectivity

to

all

services

ansible.builtin.wait_for:

host:

"
{{ item.host }}
"

port:

"
{{ item.port }}
"

timeout:

30

loop:

"
{{ connectivity_checks }}
"

loop_control:

label:

"
{{ item.name }}
"
-

name:

"Test 2 — Windows services"

hosts:

windows

gather_facts:

false

tasks:

-

name:

Verify

Wazuh

agent

running

ansible.windows.win_service_info:

name:

WazuhSvc

register:

wazuh_info

failed_when:

wazuh_info.services

|

length

==

0

or

wazuh_info.services[0].state

!=

"started"
-

name:

"Test 3 — Active Directory state"

hosts:

dc01

gather_facts:

false

tasks:

-

name:

Verify

domain

functional

ansible.windows.win_powershell:

script:

|
$domain = Get-ADDomain -ErrorAction Stop
if ($domain.DNSRoot -ne "novatech.local") {
Write-Error "Domain mismatch: $($domain.DNSRoot)"; exit 1
}
Write-Output "Domain OK: $($domain.DNSRoot)"

-

name:

Verify

all

lab

users

present

ansible.windows.win_powershell:

script:

|
$users = @("jsmith", "svc_ldap", "svc_backup")
foreach ($u in $users) {
if (-not (Get-ADUser $u -ErrorAction SilentlyContinue)) {
Write-Error "Missing user: $u"; exit 1
}
}
Write-Output "All lab users present"

-

name:

Verify

svc_backup

SPN

set

(Kerberoastable)

ansible.windows.win_powershell:

script:

|
$spns = (Get-ADUser svc_backup -Properties ServicePrincipalNames).ServicePrincipalNames
if ($spns -notcontains "MSSQLSvc/fs01.novatech.local:1433") {
Write-Error "Kerberoastable SPN missing"; exit 1
}
Write-Output "SPN OK: $spns"

-

name:

Verify

Directory

Service

Access

auditing

enabled

ansible.windows.win_powershell:

script:

|
$result = auditpol /get /subcategory:"Directory Service Access"
if ($result -notmatch "Success") {
Write-Error "DS Access auditing not enabled — EID 4662 will not fire"; exit 1
}
Write-Output "Audit policy OK"
-

name:

"Test 4 — Detection validation (fire test payloads)"

hosts:

localhost

gather_facts:

false

tasks:

-

name:

Fire

JNDI

DNS

probe

(benign



no

code

execution)

ansible.builtin.uri:

url:

http://192.168.10.100:8080/

method:

GET

headers:

X-Api-Version:

"${jndi:dns://10.0.0.20/test-probe}"

validate_certs:

false

status_code:
[
200
,
400
,
401
,
500
]

register:

probe_result

-

name:

Wait

10

seconds

for

Zeek

to

process

ansible.builtin.pause:

seconds:

10

-

name:

Check

Zeek

raised

Log4Shell

NOTICE

ansible.builtin.shell:

|
docker exec dragonrx_zeek sh -c \
'grep -l "Log4Shell" notice.log 2>/dev/null | head -1'

register:

zeek_detection

delegate_to:

localhost

failed_when:

zeek_detection.stdout

==

""

changed_when:

false

-

name:

Wait

for

Wazuh

Windows

agents

to

enroll

(up

to

3

min

after

fresh

deploy)

ansible.builtin.shell:

|
TOKEN=$(curl -sk -u wazuh:wazuh -X POST \
https://127.0.0.1:55000/security/user/authenticate \
| python3 -c "import sys,json; print(json.load(sys.stdin)['data']['token'])")
curl -sk -H "Authorization: Bearer $TOKEN" \
"https://127.0.0.1:55000/agents?status=active&os.platform=windows" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['data']['total_affected_items'])"

register:

wazuh_agents

until:

wazuh_agents.stdout

|

int

>

0

retries:

12

delay:

15

changed_when:

false

failed_when:

wazuh_agents.stdout

|

int

==

0

-

name:

"DETECTION REPORT"

ansible.builtin.debug:

msg:

-

"JNDI probe HTTP status :
{{ probe_result.status }}
"

-

"Zeek notice.log :
{{ zeek_detection.stdout | default('NOT DETECTED') }}
"

-

"Wazuh active Windows :
{{ wazuh_agents.stdout }}
"

> Test notes:

  • Zeek check:grep -l "Log4Shell" notice.logchecks the actual NOTICE string written bylocal.zeek. Avoidgrep -i "jndi" *.log— Zeek's random connection UIDs (e.g.CIikJNDIbKGxdH7Gl) contain "JNDI" and produce false positives.

  • **Wazuh check:**Uses the Wazuh REST API (JWT auth) withretries: 12, delay: 15(3-minute window). Elasticsearch is not used here — no Filebeat/forwarder is configured, sowazuh-alerts-*indices may be empty. The shell task re-fetches the JWT on each retry so it doesn't expire mid-loop.

Zeek Config

siem/zeek/entrypoint.sh

#!/bin/sh
set
-e
# Parse /proc/net/route (no iproute2 required) to find the lab bridge interface.
# Destinations are stored as little-endian hex:
# 192.168.10.0/24 = 000AA8C0 (target network - priority: probe + Windows VMs)
# 10.0.0.0/24 = 0000000A (attacker network - fallback)
IFACE=$(awk
'$2 == "000AA8C0" {print $1; exit}'
/proc/net/route)
[ -z
"
$IFACE
"
] && IFACE=$(awk
'$2 == "0000000A" {print $1; exit}'
/proc/net/route)
[ -z
"
$IFACE
"
] && IFACE=
"any"
echo

"[zeek] Capturing on:
$IFACE
"
exec
zeek -C -i
"
$IFACE
"
/usr/local/zeek/share/zeek/site/local.zeek

> Why this file exists:

  • zeek -i anyonly captures traffic on whatever the kernel routes first — on this host that was the WiFi interface (192.168.1.x internet traffic), not the Docker bridge. The Docker bridge must be specified explicitly.

  • Thezeek:6.2.1image has noip/iproute2binary./proc/net/routeprovides interface → destination routing without any tools (awk is in busybox).

  • The-Cflag skips checksum validation. Docker bridge packets have placeholder TCP checksums from NIC offloading; without-C, Zeek silently discards all bridge packets and only logs areporter.logwarning.

  • Zeek 6.2.1 accepts only a single-iflag;target_net(192.168.10.0/24) is tried first because it carries both JNDI probes and Windows VM traffic.

siem/zeek/local.zeek

@load base/protocols/http
@load base/protocols/dns
@load base/protocols/ssl
@load base/protocols/smb
# Log4Shell JNDI detection
event
http_header
(
c
: connection,
is_orig
:
bool
,
name
:
string
,
value
:
string
) {

if
( is_orig && /\$\{[a-zA-Z0-
9
_\-:\/\.]*jndi[a-zA-Z0-
9
_\-:\/\.]*:/ in value ) {

NOTICE
([
$note
=
Notice
::
LOG
,
$conn
=c,

$msg
=
fmt
(
"Log4Shell JNDI in header %s: %s"
, name, value),

$identifier
=
cat
(c
$id
)]);
}
}
# DNS tunnel heuristic - labels >40 chars are characteristic of data-exfil tunnels
# (legitimate public labels rarely exceed 30 chars)
event
dns_request
(
c
: connection,
msg
: dns_msg,
query
:
string
,
qtype
: count,
qclass
: count) {
local parts =
split_string
(query, /\./);

if
( |parts| >
0
) {
local first_label = parts[
0
];

if
( |first_label| >
40
)

NOTICE
([
$note
=
Notice
::
LOG
,
$conn
=c,

$msg
=
fmt
(
"Suspicious long DNS label (tunnel?): %s"
, query),

$identifier
=
cat
(c
$id
)]);
}
}

Host Networking Setup

scripts/setup_routing.sh

#!/usr/bin/env bash
# Enable IP forwarding and iptables rules so the attacker network
# (10.0.0.0/24) can reach the target network (192.168.10.0/24).
#
# VMs bridge directly onto the Docker target_net bridge (see Vagrantfile),
# so no host-only adapter or vboxnet0 configuration is needed here.
#
# Run once: after 'docker compose up -d', before 'vagrant up'.
set
-euo pipefail
RED=
'\033[0;31m'
; GRN=
'\033[0;32m'
; NC=
'\033[0m'
info
() {
echo
-e
"
${GRN}
[*]
${NC}
$*"
; }
error
() {
echo
-e
"
${RED}
[!]
${NC}
$*"
>&2;
exit
1; }
# ── VirtualBox network policy (6.1.28+) ──────────────────────────────────────
# Allow 192.168.10.0/24 so VBox doesn't reject bridge/host-only config calls.
if
! grep -qsE
'^\*|192\.168\.10'
/etc/vbox/networks.conf 2>/dev/null;
then
info
"Whitelisting 192.168.10.0/24 in VirtualBox network policy..."
sudo
mkdir
-p /etc/vbox

echo

"* 192.168.10.0/24 ::/0"
| sudo
tee
/etc/vbox/networks.conf >/dev/null
fi
# ── IP forwarding ─────────────────────────────────────────────────────────────
info
"Enabling IP forwarding..."
sudo sysctl -w net.ipv4.ip_forward=1 >/dev/null
echo

"net.ipv4.ip_forward=1"
| sudo
tee
/etc/sysctl.d/99-dragonrx.conf >/dev/null
# ── Locate Docker bridges ─────────────────────────────────────────────────────
info
"Locating Docker network bridges..."
ATK_NET_ID=$(docker network
ls
--filter name=attacker_net --format
"{{.ID}}"
|
head
-1)
[[ -z
"
$ATK_NET_ID
"
]] && error
"attacker_net bridge not found — run 'docker compose up -d' first"
ATK_BRIDGE=
"br-
${ATK_NET_ID:0:12}
"
TGT_NET_ID=$(docker network
ls
--filter name=target_net --format
"{{.ID}}"
|
head
-1)
[[ -z
"
$TGT_NET_ID
"
]] && error
"target_net bridge not found — run 'docker compose up -d' first"
TGT_BRIDGE=
"br-
${TGT_NET_ID:0:12}
"
info
"Attacker bridge :
$ATK_BRIDGE
(10.0.0.0/24)"
info
"Target bridge :
$TGT_BRIDGE
(192.168.10.0/24)"
# ── iptables forwarding between the two bridges ───────────────────────────────
info
"Adding iptables FORWARD rules (attacker ↔ target)..."
sudo iptables -I FORWARD -i
"
$ATK_BRIDGE
"
-o
"
$TGT_BRIDGE
"
-j ACCEPT 2>/dev/null ||
true
sudo iptables -I FORWARD -i
"
$TGT_BRIDGE
"
-o
"
$ATK_BRIDGE
"
-j ACCEPT 2>/dev/null ||
true
sudo iptables -t nat -I POSTROUTING -s 10.0.0.0/24 -d 192.168.10.0/24 -j MASQUERADE 2>/dev/null ||
true
sudo iptables -t nat -I POSTROUTING -s 192.168.10.0/24 -d 10.0.0.0/24 -j MASQUERADE 2>/dev/null ||
true
info
"Setting promiscuous mode on bridges..."
sudo ip
link

set

"
$ATK_BRIDGE
"
promisc on
sudo ip
link

set

"
$TGT_BRIDGE
"
promisc on
# Disable TX checksum offloading on the target bridge so Docker containers
# emit packets with valid TCP checksums. Without this, Windows VMs silently
# drop SYN-ACKs from Docker containers (checksum placeholder = 0x0000),
# causing Wazuh agent enrollment (port 1515) and data (port 1514) to hang.
info
"Disabling TX checksum offloading on target bridge (fixes Windows ↔ container TCP)..."
sudo ethtool -K
"
$TGT_BRIDGE
"
tx off 2>/dev/null ||
true
# ── Remove stale vboxnet0 route (conflicts with Docker bridge route) ──────────
# vboxnet0 may persist after prior host-only deployments; its kernel route for
# 192.168.10.0/24 shadows the Docker bridge route, breaking host→container reach.
if
ip route show dev vboxnet0 2>/dev/null | grep -q
'192.168.10'
;
then
info
"Removing stale vboxnet0 route for 192.168.10.0/24..."
sudo ip route del 192.168.10.0/24 dev vboxnet0 2>/dev/null ||
true
fi
echo

""
info
"Routing configured."
echo

" Attacker (
$ATK_BRIDGE
10.0.0.0/24) ↔ Target (
$TGT_BRIDGE
192.168.10.0/24)"
echo

" Windows VMs bridge directly onto
$TGT_BRIDGE
via Vagrantfile."

Makefile

.PHONY: up down
reset
test attack status logs shell deps
ANSIBLE_DIR := ansible
PLAYBOOK_FLAGS := -v
# ─────────────────────────────────────────────────────────────────────────────
up: deps
@echo
"==> [1/5] Building rxphage implant (Linux ELF + Windows PE)..."
docker compose build rxphage_builder
@echo
"==> [2/5] Starting Docker services..."
docker compose up -d
@echo
" Waiting for Wazuh to initialise..."
@until docker
exec
dragonrx_wazuh pgrep wazuh-analysisd >
/dev/null

2
>&
1
;
do

sleep

5
; done
@docker cp siem/wazuh/rules/dragonrx_rules.xml dragonrx_wazuh:
/var/
ossec/etc/rules/
2
>
/dev/null
|| true
@docker
exec
dragonrx_wazuh /var/ossec/bin/wazuh-control restart >
/dev/null

2
>&
1
|| true
@echo
"==> [3/5] Configuring host routing (Docker ↔ VirtualBox)..."
bash scripts/setup_routing.sh
@echo
"==> [4/5] Starting Windows VMs..."
vagrant up --provider virtualbox
@echo
"==> [5/5] Running Ansible provisioning..."
cd $(ANSIBLE_DIR) && ansible-galaxy collection install -r requirements.yml
cd $(ANSIBLE_DIR) && ansible-playbook playbooks/deploy.yml $(PLAYBOOK_FLAGS)
@echo
""
@echo
"==> Lab ready."
@echo
" Kibana : http://localhost:5601"
@echo
" Kali : make shell"
@echo
" Sliver : docker exec -it dragonrx_c2 sliver"
down:
vagrant halt
docker compose down
@echo
"==> Lab stopped. Data volumes preserved."
reset
:
@echo
"==> Full reset — destroying all state..."
-vagrant destroy -f
docker compose down -v
@echo
"==> Done. Run 'make up' to redeploy."
test:
cd $(ANSIBLE_DIR) && ansible-playbook playbooks/test.yml $(PLAYBOOK_FLAGS)
attack:
docker
exec
-it dragonrx_kali bash /opt/tools/run_attack.sh
# ─────────────────────────────────────────────────────────────────────────────
status:
@echo
"--- Docker ---"
docker compose ps
@echo
""
@echo
"--- Vagrant ---"
vagrant status
logs:
docker compose logs -f --tail=
50
shell:
docker
exec
-it dragonrx_kali /bin/bash
deps:
@command -v docker >
/dev/null

2
>&
1
|| (echo
"ERROR: docker not found"
&&
exit

1
)
@docker compose version >
/dev/null

2
>&
1
|| (echo
"ERROR: 'docker compose' (v2 plugin) not found — upgrade Docker Desktop or install the compose plugin"
&&
exit

1
)
@command -v vagrant >
/dev/null

2
>&
1
|| (echo
"ERROR: vagrant not found"
&&
exit

1
)
@command -v ansible >
/dev/null

2
>&
1
|| (echo
"ERROR: ansible not found — pip3 install ansible"
&&
exit

1
)
@command -v VBoxManage >
/dev/null

2
>&
1
|| (echo
"ERROR: VBoxManage not found"
&&
exit

1
)
@python3 -c
"import winrm"

2
>
/dev/null
|| (echo
"ERROR: pywinrm not installed — pip3 install pywinrm"
&&
exit

1
)
@vagrant plugin list
2
>
/dev/null
|
grep
-
q
vagrant-reload || (echo
"ERROR: vagrant plugin 'vagrant-reload' missing — vagrant plugin install vagrant-reload"
&&
exit

1
)
@vagrant plugin list
2
>
/dev/null
|
grep
-
q
vagrant-hostmanager || (echo
"ERROR: vagrant plugin 'vagrant-hostmanager' missing — vagrant plugin install vagrant-hostmanager"
&&
exit

1
)
@vagrant box list
2
>
/dev/null
|
grep
-
q

"StefanScherer/windows_2019"
|| \
(echo
"WARN: Vagrant box StefanScherer/windows_2019 not cached locally — 'vagrant up' will download it (~8 GB)"
)
@vagrant box list
2
>
/dev/null
|
grep
-
q

"StefanScherer/windows_10"
|| \
(echo
"WARN: Vagrant box StefanScherer/windows_10 not cached locally — 'vagrant up' will download it (~8 GB)"
)
@echo
"[+] All prerequisites satisfied."

Deploy Sequence

make up

├─ deps
check
docker
/
vagrant
/
ansible
/
VBoxManage
├─ docker compose up
-
d
start
Linux containers (Kali, Sliver, JNDI, Web01, SIEM)
├─ scripts
/
setup_routing.sh bridge Docker target_net ↔ VirtualBox vboxnet0
├─ vagrant up boot DC01
+
FS01
+
WS01 (downloads boxes
on

first
run
~
25
GB)
└─ ansible
-
playbook deploy.yml

├─ Phase
1
verify Docker services healthy (Wazuh API
+
Kibana)
├─ Phase
2
DC01:
set
hostname → AD DS install → reboot →
set
admin password →
│ domain promotion → reboot → users → SPNs → audit policy → SACL → Wazuh agent
├─ Phase
3
FS01:
set
hostname →
set
DNS → domain
join
→ reboot →
│ crown jewel data → SMB shares → Wazuh agent
├─ Phase
4
WS01:
set
hostname →
set
DNS → domain
join
→ reboot →
│ SMB firewall → jsmith
local
admin → C:\Temp → Wazuh agent
├─ Phase
5
SIEM: Kibana index patterns (wazuh
-
alerts
-
*
, zeek
-
*
)
└─ Phase
6
test.yml: connectivity, Wazuh service, AD state, detection validation

Expected duration:

Article screenshot

VM Specifications

Three Windows VMs managed by Vagrant and VirtualBox. All use WinRM for Ansible communication; no manual RDP setup required.

Article screenshot

DC01 — Domain Controller (**192.168.10.10****)**Runs AD DS fornovatech.local. Ansible configures: domain promotion, DNS, user accounts (jsmith,svc_ldap,svc_backup), Kerberoastable SPN (MSSQLSvc/fs01.novatech.local:1433), Directory Service Access audit policy, SACL on the domain naming context (EID 4662 for DCSync detection), Wazuh agent enrollment.

FS01 — File Server (**192.168.10.20****)**Joined tonovatech.local. Hosts two SMB shares:

  • \\FS01\Research— synthetic Phase III clinical trial documents (CSVs, PDFs)

  • \\FS01\Manufacturing— synthetic formulation data (DOCX)

Crown jewel data is generated by Ansible via PowerShell. This is the primary collection and exfiltration target.

WS01 — Researcher Workstation (**192.168.10.50****)**Windows 10 joined to the domain. Userjsmithhas local admin rights. Used as a lateral movement pivot target (Pass-the-Hash / Pass-the-Ticket from DC01 credential dump).

Full Lab Environment Summary

Article screenshot

Minimum host requirements:

Article screenshot

Access points once deployed:

Article screenshot

Follow for practical cybersecurity research

If you’re interested in**Offensive security,**AI security, real-world attack simulations, CTI, and detection engineering— this is exactly what I focus on.

Stay connected:

Subscribe on Medium:medium.com/@1200kmConnect on LinkedIn:andrey-pautovGitHub — tools & labs:github.com/anpa1200Contact:1200km@gmail.com