case study
Network Architecture
In the Network Architecture phase I took the Enterprise Homelab and turned it into a small segmented environment with containerized services and a real firewall edge. I used Docker on a Linux host to run separate DNS, FTP, and HTTP web containers on an isolated bridge network so each service had its own IP and hostname while still being reachable from the rest of the lab through controlled port publishing.
On the network side I introduced pfSense as a router/firewall between the internal lab network and the segment hosting those containerized services, effectively creating a simple LAN/DMZ layout instead of one big broadcast domain. On the host side I used the Windows Firewall playground to practice tightening and breaking connectivity from the client perspective, and then layered Suricata in as an IPS on the pfSense side so the same architecture could later generate real detections when I started attacking the DNS, FTP, and web services.
###Setup DNS Container
-Virtual Machine [project-zero-corp-svr] is configured with Docker.
I built a Dockerized BIND DNS server that hosts the projectzerocorp.com zone so lab machines can resolve internal services by name instead of IP. This container runs on the Docker network but answers queries from the rest of the lab over UDP/TCP 53.
1.Building the DNS image
Ran docker build from the dns directory to build a custom Ubuntu‑based image with BIND installed and my config/zone files baked in.
2.named.conf – include custom zone
Edited named.conf to include the default options and added a projectzerocorp.com master zone pointing at my zone file under /etc/bind/zones/db.projectzerocorp.com.
3.Start the BIND9 Service: service named start
4.Used dig @10.0.0.8 www.projectzerocorp.com to confirm that the DNS server returns the expected A record and that name resolution matches the zone file.
###Configure SSH Access
To make the DNS container manageable from the rest of the lab, I enabled SSH access on a non‑standard port with relaxed settings. This also gives me a deliberately weak SSH configuration I can come back to later as an attack target and hardening example.
1.Editing sshd_config for remote access
Updated /etc/ssh/sshd_config on the DNS host to listen on port 2222 instead of the default 22 so SSH is reachable for management without clashing with other services in the lab.
Changed the following settings:
2.Intentionally weak SSH settings for later hardening
Enabled PermitRootLogin yes, PasswordAuthentication yes, and PermitEmptyPasswords yes inside the container to simulate an insecure SSH service that I can attack and later lock down in the Network Defense phase.
3.Verifying SSH and DNS are listening
Used netstat -tuln to confirm that DNS is bound to the expected interfaces on port 53 and that SSH is listening on 0.0.0.0:2222, ready for remote connections from other lab machines.
Why this matters: Enabling SSH on a custom port with intentionally weak settings turns the DNS server into a realistic remote‑access target instead of just a static container. It gives you a safe place to practice brute‑force and misconfiguration attacks now, and later to demonstrate hardening by tightening SSH controls and showing the before/after impact on the lab.
###Setup FTP Container
I set up a Dockerized vsftpd server so the lab has a simple, standalone FTP service to use as an attack target and for later monitoring. I compiled vsftpd from source inside the container, created a dedicated ftp user, and verified that the service is listening on the host so other machines in the lab can reach it.
1.Build and run the FTP container
Started by building the project-zero-image-ftp image and running it with host networking so whatever I expose in the container is directly reachable on the lab network. This gives me an easy way to host FTP without changing the rest of the topology.
2.Clone vsftpd source into the workspace
Inside the running container, I cloned the official vsftpd 2.3.4 source so I could compile it locally. Lets me tweak build options instead of relying on a prebuilt package.
3.Adjust Makefile to add PAM library
4.Compile vsftpd from source
With the Makefile updated, I ran make to build vsftpd. This step produces the actual server binary that the container will run.
5.Create FTP user account
I created a dedicated ftp user and group so the service runs with its own low‑privilege account. That keeps FTP access separate from root and normal user accounts.
6.Prepare the FTP root directory
Next, I created /usr/share/empty and set tight permissions for it as the chroot directory. This is where vsftpd will jail anonymous or FTP sessions so they cannot wander the rest of the filesystem.
7.Verify FTP port is listening
Finally, I used netstat -tuln to confirm that the FTP daemon is bound and listening on the expected port on the host. This proves the containerized service is reachable from other lab systems and ready for later attack/defense work.
Why this matters: Setting up a dedicated vsftpd container gives the lab a realistic FTP service that behaves like something you would actually find in a production network. It becomes a controlled target for the Network lab, letting you practice credential attacks, misconfiguration abuse, and detection tuning against a service you fully understand and can safely break and fix.
###Setup HTTP Web Container
Using a simple internal web page keeps the focus on NGINX and HTTP behavior rather than front‑end design. The point is to have a real HTTP service in the lab that clients can talk to, attack, and monitor like they would on an intranet.
1.File 1: index.html
A minimal web page that pretends to be an internal site, just enough to prove that NGINX is serving content correctly. The goal is to have a real HTTP response that clients and tools in the lab can reach and test against.
Inside the ~/home/web folder. Create a new index.html file with nano:
sudo nano index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>ProjectZero Login</title>
<style>
body{
font-family:Arial,sans-serif;
background:#f4f4f4;
padding:40px;
}
h1{
color:#2a7ae2;
}
.login-box,.content-box{
background:white;
padding:20px;
max-width:400px;
margin:auto;
border-radius:8px;
box-shadow:0010pxrgba(0,0,0,0.1);
}
input{
width:100%;
padding:10px;
margin:10px0;
border:1pxsolid#ccc;
border-radius:4px;
}
button{
width:100%;
padding:10px;
background:#2a7ae2;
color:white;
border:none;
border-radius:4px;
cursor:pointer;
}
.error{
color:red;
}
</style>
</head>
<body>
<div class="login-box" id="login-box">
<h1>Login to ProjectZero</h1>
<input type="text" id="username" placeholder="Email or Username">
<input type="password" id="password" placeholder="Password">
<button onclick="login()">Login</button>
<p class="error" id="error-msg"></p>
</div>
<div class="content-box" id="content-box" style="display:none;">
<h1>Welcome to ProjectZero Internal Portal</h1>
<p>Here's the latest update on what's happening in ProjectZero:</p>
<ul>
<li>Security audit scheduled for next week.</li>
<li>New feature rollout planned for June 30.</li>
<li>Remember to update your credentials regularly!</li>
</ul>
</div>
<script>
constusers={
'Administrator':'admin',
'johnd@corp.project-zero-dc.com':'smile',
'janed@corp.project-zero-dc.com':'@password123!'
};
functionlogin(){
constusername=document.getElementById('username').value.trim();
constpassword=document.getElementById('password').value;
consterrorMsg=document.getElementById('error-msg');
if(users[username]&&users[username]===password){
document.getElementById('login-box').style.display='none';
document.getElementById('content-box').style.display='block';
}else{
errorMsg.textContent='Invalid username or password.';
}
}
</script>
</body>
</html>2.File 2: nginx.conf
Tells NGINX where to find my site and how to serve it, replacing the default config with something I fully control. This is what actually wires the container’s HTTP port to my internal page, which is what later attacks and monitoring will hit.
The nginx.conf file is a default NGINX file used to set the port to listen on, configure the domain, copy the index.html file, and add routing settings.
sudo nano nginx.conf
events {}
http {
server {
listen 80;
# server_name projectzerocorp.com;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
}I copy both config files into NGINX’s default locations so the container uses my index.html and nginx.conf when it starts.
3.File 3: Dockerfile
Defines the NGINX web image for the lab and copies in my custom config and HTML so the container always starts with the exact site and settings I want. Using a Dockerfile makes the web service reproducible and easy to rebuild if I break it.
sudo nano Dockerfile
FROM nginx:latest
COPY index.html /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/nginx.conf4.Provision Container
First, let's build the docker image.
docker build -t projectzero-image-web
5.Navigate to Firefox under [project-zero-corp-svr]. Enter http://localhost
###Deploy and configure pfSense
I’ve deployed a pfSense firewall into my lab to implement a segmented WAN, LAN, and DMZ architecture. I provisioned a pfSense VM, mapped its interfaces to the correct VirtualBox networks, and assigned the appropriate IP ranges for both the LAN and DMZ. I can now reach the pfSense web GUI and initial setup wizard from my management workstation over the LAN, confirming that the firewall is fully installed, online, and ready to enforce stateful policies between the internal network, the DMZ, and the upstream WAN.
1.Console Interface Summary
The pfSense console menu after installation, highlighting the WAN, LAN, and DMZ (OPT1) interfaces and their assigned IPv4 addresses. It confirms that the WAN is bound to the external network, the LAN to the internal management segment, and the DMZ to the isolated subnet using the expected CIDR ranges (192.168.15.0/24 and 10.0.5.0/24).
2.Web GUI Login Page
This screenshot shows me reaching the pfSense web configurator over the LAN IP and logging in with the default admin account. It proves my linux host can talk to the firewall over the host‑only LAN and that routing and IP assignments are correct. Reaching this login page confirms my pfSense deployment is healthy and ready for further configuration in the web interface.
3.Initial Setup Wizard
This is the first page of the pfSense setup wizard right after I log in. Confirming pfSense is fully accessible and ready for further firewall configuration.
###Create DMZ Interface
I extended pfSense with a third interface dedicated to the DMZ network so I could isolate public facing services from the internal LAN. This creates a separate subnet for the web, DNS, and FTP containers, with routing and firewalling controlled in one place.
1.Assigning the new DMZ port
In the Interfaces → Assignments view, I bind the unused physical NIC (em2) as a new interface so pfSense can treat it as a separate network for the DMZ.
2.Enabling and naming the DMZ Interface
After assignment, I enable the new OPT interface, rename it to DMZ, and give it a static IPv4 address in the 10.0.5.0/24 range that will serve as the default gateway for all DMZ hosts.
###DHCP & DNS Routing on LAN Interface
On the LAN side of pfSense, I turned it into the central DHCP and DNS gateway for the internal subnet. The firewall now hands out IP leases in the 192.168.15.0/24 range and tells clients which DNS server to use, so all LAN hosts get consistent addressing and name resolution.
1.Enabling DHCP on the LAN interface & defining the LAN address pool
In Services → DHCP Server → LAN, I enabled the DHCP service so pfSense can automatically assign IP addresses to machines on the internal network instead of configuring each host by hand.
I set the LAN subnet and a specific address range (for example 192.168.15.10–192.168.15.150) that the DHCP server is allowed to use, keeping the rest of the space free for static assignments like servers and infrastructure.
2.Pointing clients at the correct DNS servers
Under Server Options, I added 8.8.8.8 as the DNS server, so every DHCP lease instructs clients to send DNS queries directly to Google’s public resolver rather than relying on an internal DNS service.
###DHCP & DNS Routing on DMZ Interface
On the DMZ interface, I turned pfSense into the DHCP and DNS gateway for the 10.0.5.0/24 network that hosts the public facing containers. This lets pfSense hand out DMZ IPs and tell those services to use Google DNS, while keeping their addressing fully separate from the LAN.
1.Enabling DHCP on the DMZ Interface and defining the DMZ address pool
In the DHCP Server tab for the DMZ interface, I enabled DHCP so pfSense can automatically assign addresses to systems in the DMZ instead of configuring every container or VM manually.
I set the subnet to 10.0.5.0/24 and created a smaller dynamic range (for example 10.0.5.3–10.0.5.54) that pfSense is allowed to use, leaving other addresses free for static assignments like the firewall gateway or pinned services.
2.Using Google DNS for DMZ clients
Under Server Options, I added 8.8.8.8 as the DNS server so DMZ systems can resolve external domains through Google’s public DNS without depending on internal name services.
###Attach VMs to project-zero-corp-fw
I attached my windows-client, linux-client,corp-svr VMs to the corp-fw pfSense firewall using the predefined host‑only networks. I mapped vboxnet1 to the LAN (192.168.15.0/24) and vboxnet0 to the DMZ (10.0.5.0/24), then updated each VM’s network adapters to use the correct host‑only interface. After rebooting the VMs, pfSense’s DHCP server issued addresses in the expected ranges, confirming that all machines are now behind the corp-fw firewall and reachable over their respective LAN and DMZ segments.
###Configure pfSense FW Rules
Inside the LAN segment, we want our workstations and employees to access all resources related to Project Zero DMZ, while being able to access the Internet.
Subnet Zones
-✅ Source: LAN Subnets | Destination: DMZ Subnets
-❌ Source: DMZ Subnets | Destination: LAN Subnets
Allowed Ports
-ICMP
-TCP 80
-TCP 443
-TCP/UDP 53
-TCP 25
-TCP 21
-TCP 20
1.Building LAN → DMZ rules
In the LAN rules editor, I create a pass rule that applies to IPv4 TCP traffic from the LAN subnets to the DMZ subnets, restricted to the HTTP (80) destination port range. This pattern is then reused for other allowed ports, so each protocol has its own explicit rule instead of one wide open policy.
2.Final LAN rules set
The final LAN ruleset shows a tidy list of allowed paths: one anti‑lockout rule, a rule for ICMP, and separate TCP rules that let LAN clients reach DMZ services on DNS (53), SMTP (25), FTP (21), HTTPS (443), and HTTP (80), plus a default IPv6 allow rule. Anything not matching these rules is implicitly blocked, which limits how workstations can talk to the DMZ and the internet.
For the DMZ, we want to give limited public access to the assets we intend on exposing to the world, in our case it is our web server.
The goal is to expose only what is needed for public access to the lab’s web services while still letting internal tools talk to the DMZ safely. To do that, pfSense has a small set of DMZ rules that (1) allow monitoring traffic from the security box to the DMZ range and (2) allow DMZ hosts to talk back to the LAN only on specific service ports.
Subnet Zones
-❌ Source: DMZ Subnets | Destination: LAN Subnets
Allowed Ports
-Source: 10.0.5.3 | Destination: 192.168.15.50 (Hardcoding our [project-zero-sec-box] IP address to allow agents to communicate)
-TCP/UDP 53
-TCP 80
-TCP 443
-TCP 21
-TCP 20
-Protocol ICMP *
3.Final DMZ rules set
For the DMZ segment, I wanted limited public access to the services I plan to expose, plus controlled communication back to the LAN for management and monitoring. I used pfSense rules with DMZ subnets as the source zone and LAN subnets as the destination zone, but only for a handful of TCP and UDP ports (DNS 53, HTTP 80, HTTPS 443, FTP 21, and ICMP). This “allow the whole DMZ subnet but only on specific ports” pattern gives flexibility for multiple DMZ hosts without opening up every protocol.
I added two special rules that allow traffic from the DMZ subnet back to the IP of my security box (192.168.15.50/24) so monitoring agents can call home from the DMZ without giving that host broad inbound access.
###Suricata IPS
-Virtual Machine [project-zero-sec-work] installed and configured.
In Part 1, I deployed Suricata on my [project-zero-sec-work] VM by installing it from the OISF repository, updating the configuration to use the enp0s3 interface, and enabling the Suricata systemd service. I verified with systemctl status that the Suricata Intrusion Detection Service is running and monitoring the correct network interface.
I first updated the system and installed Nano:
sudo yum update -y
sudo yum install nano
Next, I enabled the official OISF repository and installed Suricata:
sudo yum install epel-release yum-plugin-copr
sudo yum copr enable @oisf/suricata-7.0
sudo yum install suricata
By default, Suricata listens on the eth0 interface, but Security Onion uses enp0s3, so I updated the Suricata config under /etc/sysconfig/suricata:
sudo nano /etc/sysconfig/suricata
# Changed:
OPTIONS="-i enp0s3 --user suricata"Then I enabled and started the Suricata service and verified that it came up cleanly:
sudo systemctl enable suricata.service
sudo systemctl start suricata.service
sudo systemctl status suricata.service
The systemctl status output shows Suricata running as the “Suricata Intrusion Detection Service” on enp0s3, confirming the IDS is active and monitoring the correct network interface on [project-zero-sec-work].
In Part 2, I’m configuring Suricata as an IPS, enabling the community‑id feature for better correlation, and loading the Emerging Threats Open ruleset so my sensor can start generating useful alerts. While I configure Suricata, I also turn on a few log normalization settings so that Suricata’s logs are ready for ingestion into Wazuh later.
https://www.criticaldesign.net/post/how-to-setup-a-suricata-ips
First, I switch to root and open the main Suricata configuration file:
sudo su -cd /etc/suricatanano suricata.yaml
The community-id option generates a unique hash per network flow so I can correlate the same connection across different tools, including Wazuh. I enable it by setting
community-id: trueNext, I update the capture settings to make sure Suricata listens on my [project-zero-sec-work] interface. Under the af-packet section, I set the interface to enp0s3:
af-packet: - interface: enp0s3After saving the changes, I restart Suricata to apply the new configuration:
sudo systemctl restart suricata
Now I configure Suricata’s rules with suricata-update. I list the available sources, enable the Proofpoint Emerging Threats Open ruleset, and then pull the latest rules.
sudo suricata-update list-sources
sudo suricata-update enable-source et/open
sudo suricata-update
Finally, I verify that the configuration and ruleset load cleanly:
sudo suricata -T -c /etc/suricata/suricata.yaml -v
The output shows Suricata loading the ET Open rules without errors and confirms that my configuration is valid, so Suricata is now running with updated rules and ready to act as an IPS on enp0s3
To run Suricata in IPS mode, I first update suricata.yaml so packets are redirected to the Netfilter queue (NFQUEUE).docs.suricata+1
I open the config as root:
sudo nano /etc/suricata/suricata.yaml
Under the NFQUEUE section, I set the queue ID and enable fail-open so traffic is allowed if Suricata can’t keep up:
nfq:
id: 0
accept-mark: 1
fail-open: yesThese options let Suricata inspect packets from queue 0 and decide whether they are forwarded, dropped, or modified, while the kernel falls back to allowing traffic if Suricata is overloaded.
By default, iptables rules are not persistent and are lost on reboot, so this method is mainly for quick testing.
I add rules to send packets into NFQUEUE 0:
sudo iptables -I INPUT -j NFQUEUE --queue-num 0 sudo iptables -I FORWARD -j NFQUEUE --queue-num 0 sudo iptables -I OUTPUT -j NFQUEUE --queue-num 0
With these rules in place, I can start Suricata in NFQUEUE IPS mode:
sudo suricata -c /etc/suricata/suricata.yaml -q 0 -v
This tells Suricata to read from NFQUEUE 0 and enforce IPS actions on packets that match my rules.
For a persistent setup, I switch to nftables so the NFQUEUE rules survive reboot.
I first add a filter table:
sudo nft add table inet filter
Then I add chains for input and forward traffic:
sudo nft add chain inet filter input '{ type filter hook input priority 0; policy accept; }'
sudo nft add chain inet filter forward '{ type filter hook forward priority 0; policy accept; }'
Next, I attach NFQUEUE 0 to both chains so matching packets are passed to Suricata:
sudo nft add rule inet filter input queue num 0
sudo nft add rule inet filter forward queue num 0
Finally, I enable nftables at startup and list the ruleset to confirm my changes:
sudo systemctl enable nftables
sudo nft list ruleset
The ruleset output shows the input and forward chains using NFQUEUE with queue 0, which means that when the machine reboots, packets will automatically be sent to Suricata for inline inspection.
###Part 3: Create Test Rule
We are going to create a custom rule to see if traffic will get dropped. For testing purposes, we can drop any ICMP packets, which means dropping any network packets that are attempting to ping our host.
Here is the rule we will use:
drop icmp any any -> any any (msg:"ICMP Request Blocked"; sid:2; rev:1;)
Created this rule in a new file called local.rules, in the following file location.
sudo nano /var/lib/suricata/rules/local.rules
Add the above rule to this file.
We need to add our custom rule to Suricata's configuration file, so that it knows where to locate our custom rules.
Navigate back to suricata.yaml.
sudo nano /etc/suricata/suricata.yaml
Added the following under the rule-files block.
-/var/lib/suricata/rules/local.rules.
sudo systemctl restart suricata
sudo suricata -c /etc/suricata/suricata.yaml -q 0 -v
Now if we go to any other device and issue a ping, we should see our traffic blocked.
Suricata, by default, appends its alerts to the fast.log file.
sudo tail /var/log/suricata/fast.log

















































