Project Zero: Network Lab (Part 2)

case study

Network Architecture

last edited 2026-07-28

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.

Building the custom DNS image with BIND and my zone files baked in.
Building the custom DNS image with BIND and my 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.

Updating  named.conf  to load the  projectzerocorp.com  master zone file.
Updating named.conf to load the projectzerocorp.com master zone file.

3.Start the BIND9 Service: service named start

Verifying the  named  service is running cleanly inside the container.
Verifying the named service is running cleanly inside the container.

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.

Testing resolution with  dig @10.0.0.8 www.projectzerocorp.com  from the host.
Testing resolution with dig @10.0.0.8 www.projectzerocorp.com from the host.

Why this matters: A local DNS container gives the lab real name resolution, so services can be reached by hostname instead of IP. That’s essential for realistic attack paths, logging, and later DNS‑focused detections.


###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:

Updated  /etc/ssh/sshd_config  on the DNS host to listen on port  2222  instead of 22 so SSH is reachable for management without clashing with other lab services.
Updated /etc/ssh/sshd_config on the DNS host to listen on port 2222 instead of 22 so SSH is reachable for management without clashing with other lab services.

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.

Loosening SSH authentication settings ( PermitRootLogin ,  PasswordAuthentication ,  PermitEmptyPasswords ) to deliberately keep this DNS box weak for later attack and hardening exercises.
Loosening SSH authentication settings (PermitRootLogin, PasswordAuthentication, PermitEmptyPasswords) to deliberately keep this DNS box weak for later attack and hardening exercises.

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.

Confirming with  netstat -tuln  that DNS and SSH ports are listening.
Confirming with netstat -tuln that DNS and SSH ports are listening.

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.

Built the  project-zero-image-ftp  Docker image and launched it with  --network host  so the vsftpd service is reachable on the same IP as the host.
Built the project-zero-image-ftp Docker image and launched it with --network host so the vsftpd service is reachable on the same IP as the host.
Built the  project-zero-image-ftp  Docker image and launched it with  --network host  so the vsftpd service is reachable on the same IP as the host.
Built the project-zero-image-ftp Docker image and launched it with --network host so the vsftpd service is reachable on the same IP as the host.

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.

Cloned the official vsftpd 2.3.4 repository into  /workspace  inside the container to compile a local copy of the FTP server.
Cloned the official vsftpd 2.3.4 repository into /workspace inside the container to compile a local copy of the FTP server.

3.Adjust Makefile to add PAM library

Updated the Makefile to link against  -lpam  so the compiled vsftpd binary can use PAM for authentication on this lab box.
Updated the Makefile to link against -lpam so the compiled vsftpd binary can use PAM for authentication on this lab box.

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.

Ran  make  to compile vsftpd with the updated flags, producing a custom binary.
Ran make to compile vsftpd with the updated flags, producing a custom binary.

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.

Added an  ftp  user and group so logins to the FTP service use a dedicated, low privilege account instead of root or an existing user.
Added an ftp user and group so logins to the FTP service use a dedicated, low privilege account instead of root or an existing user.

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.

Created  /usr/share/empty  with tight permissions and started vsftpd, giving the service a controlled chroot directory for anonymous/ftp sessions.
Created /usr/share/empty with tight permissions and started vsftpd, giving the service a controlled chroot directory for anonymous/ftp sessions.

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.

Used  netstat -tuln  to confirm that vsftpd is bound and listening on the expected FTP port on the host, ready for connections from other lab machines.
Used netstat -tuln to confirm that vsftpd is bound and listening on the expected FTP port on the host, ready for connections from other lab machines.

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.

Creating the  web  directory on the host and switching into it so I can build the  index.html  file that NGINX will serve.
Creating the web directory on the host and switching into it so I can build the index.html file that NGINX will serve.

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

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

bash
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

bash
FROM nginx:latest

COPY index.html /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/nginx.conf
Screenshot from project documentation

4.Provision Container

First, let's build the docker image.

docker build -t projectzero-image-web

Running  docker build  to create the  projectzero-image-web  image that will host the internal HTTP site.
Running docker build to create the projectzero-image-web image that will host the internal HTTP site.

docker run -d --name web-svr --network=host projectzero-image-web

5.Navigate to Firefox under [project-zero-corp-svr]. Enter http://localhost

Browsing to  http://localhost  from the corp server to confirm the NGINX container is serving the Project‑Zero login page correctly.
Browsing to http://localhost from the corp server to confirm the NGINX container is serving the Project‑Zero login page correctly.

###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).

pfSense console showing WAN, LAN, and DMZ interface assignments with IP addresses.
pfSense console showing WAN, LAN, and DMZ interface assignments with IP addresses.

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.

pfSense web GUI login page accessed from the management LAN IP.
pfSense web GUI login page accessed from the management LAN IP.

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.

Initial pfSense setup wizard ready to guide core configuration.
Initial pfSense setup wizard ready to guide core 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.

Screenshot from project documentation
Adding  em2  as a new interface in pfSense so it can be used as the DMZ network port.
Adding em2 as a new interface in pfSense so it can be used as the DMZ network port.

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.

Enabling the OPT interface, renaming it to  DMZ , and assigning it the  10.0.5.1/24  IP to anchor the DMZ subnet.
Enabling the OPT interface, renaming it to DMZ, and assigning it the 10.0.5.1/24 IP to anchor the DMZ subnet.
Screenshot from project documentation

###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.

Screenshot from project documentation
Configuring the LAN DHCP scope with a controlled dynamic range inside the  192.168.15.0/24  subnet.
Configuring the LAN DHCP scope with a controlled dynamic range inside the 192.168.15.0/24 subnet.

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.

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.
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.

Turning on the DHCP server on the DMZ interface so pfSense manages addressing for public‑facing services.
Turning on the DHCP server on the DMZ interface so pfSense manages addressing for public‑facing 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.

Pointing DMZ DHCP clients at Google DNS (8.8.8.8) to handle external name lookups from the DMZ network.
Pointing DMZ DHCP clients at Google DNS (8.8.8.8) to handle external name lookups from the DMZ network.

###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.

VirtualBox host‑only networks mapped to the LAN and DMZ segments.
VirtualBox host‑only networks mapped to the LAN and DMZ segments.
pfSense DHCP leases confirming all VMs are behind the  corp-fw  firewall.
pfSense DHCP leases confirming all VMs are behind the corp-fw firewall.

###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.

Screenshot from project documentation
Screenshot from project documentation
Creating a LAN rule that allows IPv4 TCP traffic from LAN subnets to DMZ subnets on HTTP (port 80).
Creating a LAN rule that allows IPv4 TCP traffic from LAN subnets to DMZ subnets on HTTP (port 80).

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.

Final pfSense LAN ruleset, with explicit per‑service rules for LAN access to DMZ and internet traffic instead of a single “allow all” rule.
Final pfSense LAN ruleset, with explicit per‑service rules for LAN access to DMZ and internet traffic instead of a single “allow all” rule.

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.

Final DMZ ruleset in pfSense, where DMZ subnets can reach LAN subnets only on selected service ports (DNS, HTTP/HTTPS, FTP, ICMP) plus two targeted rules for agents talking back to the security box.
Final DMZ ruleset in pfSense, where DMZ subnets can reach LAN subnets only on selected service ports (DNS, HTTP/HTTPS, FTP, ICMP) plus two targeted rules for agents talking back to the security box.

###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:

bash
sudo nano /etc/sysconfig/suricata
# Changed:
OPTIONS="-i enp0s3 --user suricata"
Updating Suricata to monitor the  enp0s3  interface.
Updating Suricata to monitor the enp0s3 interface.

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].

Suricata service running as the Intrusion Detection Service.
Suricata service running as the Intrusion Detection Service.

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

plain text
community-id: true
Enabling the  community-id  feature in  suricata.yaml  for flow correlation.
Enabling the community-id feature in suricata.yaml for flow correlation.

Next, 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:

bash
af-packet:  - interface: enp0s3
Binding Suricata’s  af-packet  capture interface to  enp0s3  in  suricata.yaml .
Binding Suricata’s af-packet capture interface to enp0s3 in suricata.yaml.

After 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

Enabling  community-id  and binding Suricata to  enp0s3  in  suricata.yaml .
Enabling community-id and binding Suricata to enp0s3 in suricata.yaml.

Finally, I verify that the configuration and ruleset load cleanly:

sudo suricata -T -c /etc/suricata/suricata.yaml -v

Loading the ET Open ruleset and validating the Suricata configuration.
Loading the ET Open ruleset and validating the Suricata configuration.

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:

plain text
nfq:
  id: 0
  accept-mark: 1
  fail-open: yes
Screenshot from project documentation

These 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

Created the nftables filter table and configured persistent input and forward chains to send packets to NFQUEUE for Suricata inline inspection.
Created the nftables filter table and configured persistent input and forward chains to send packets to NFQUEUE for Suricata inline inspection.

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

Verified the nftables ruleset, confirming both the input and forward chains forward matching traffic to NFQUEUE queue 0.
Verified the nftables ruleset, confirming both the input and forward chains forward matching traffic to NFQUEUE queue 0.

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.

Registered  local.rules  within the  rule-files  section of  suricata.yaml  so Suricata loads the custom detection rules at startup.
Registered local.rules within the rule-files section of suricata.yaml so Suricata loads the custom detection rules at startup.

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

Generated ICMP traffic from the attacker system to validate that the custom inline rule was evaluated by Suricata.
Generated ICMP traffic from the attacker system to validate that the custom inline rule was evaluated by Suricata.
Verified the custom ICMP drop rule executed successfully by reviewing Suricata's  fast.log , confirming the "ICMP Request Blocked" alert was generated during inline inspection.
Verified the custom ICMP drop rule executed successfully by reviewing Suricata's fast.log, confirming the "ICMP Request Blocked" alert was generated during inline inspection.