Skip to content

Security Lab

Set up key-based SSH through a bastion and harden the server, open ports with firewalld and ufw, filter and NAT on a Linux router with nftables, fix a real SELinux denial, grant one capability to a service, record changes with auditd, verify a package signature, serve TLS behind a private CA and trust it, then run an integrity and hardening sweep.


Setup

Use the same three-VM FlexBox playground as the Networking Lab: client (Ubuntu 24.04, 172.16.0.2), gw (Rocky Linux, 172.16.0.3 and 172.16.1.2, the router) and web (Rocky Linux, 172.16.1.3). Addresses can differ; check with ip -br addr. Install the tools:

# gw and web
sudo dnf install -y firewalld selinux-policy-targeted policycoreutils policycoreutils-python-utils audit openssl aide openscap-scanner scap-security-guide setools-console nftables acl nmap-ncat
# client
sudo apt-get update && sudo apt-get install -y ufw apparmor-utils auditd openssh-client gnupg debsums lynis libcap2-bin openssl

Create a working user on each server (deploy on gw and web) and a client user ops. SELinux is enforcing on both Rocky hosts; confirm with getenforce.

Do not lock yourself out

Every SSH and firewall change here can end your session. Keep a second terminal open, test a new login before closing the old one, and use the playground's own terminal as a console.


SSH and the Server

1. Key-Based Login Through a Bastion

From ops on client, create a key, reach gw directly and web through gw, and confirm the key is used.

Solution
ssh-keygen -t ed25519 -C "ops@client"          # client
ssh-copy-id deploy@172.16.0.3                   # gw is the bastion
cat >> ~/.ssh/config <<'EOF'
Host gw
    HostName 172.16.0.3
    User deploy
Host web
    HostName 172.16.1.3
    User deploy
    ProxyJump gw
EOF
chmod 600 ~/.ssh/config
ssh web 'echo $SSH_CONNECTION'                  # source is gw's dmz address
ssh -v web true 2>&1 | grep Authenticated

ProxyJump carries the encrypted session through gw; the key never lands on the bastion. On web, install the key by hand or with ssh-copy-id from gw.

2. Harden sshd

On web, disable root and password logins, limit auth tries and add a banner, testing before the reload.

Solution
echo 'Authorized use only. Activity is logged.' | sudo tee /etc/issue.net
sudo tee /etc/ssh/sshd_config.d/10-hardening.conf <<'EOF'
PermitRootLogin no
PasswordAuthentication no
MaxAuthTries 3
X11Forwarding no
Banner /etc/issue.net
EOF
sudo sshd -t && sudo systemctl reload sshd
sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|maxauthtries|banner) '

A drop-in in sshd_config.d/ is read before the main file, so its values win. sshd -t catches a typo before a reload leaves you locked out.


Firewalls

3. Open a Port With firewalld

On web, start firewalld and open the API port permanently, watching a runtime rule vanish on reload.

Solution
sudo systemctl enable --now firewalld            # web; SSH stays open by default
sudo firewall-cmd --add-port=8080/tcp            # runtime only
sudo firewall-cmd --reload                        # runtime rule gone
sudo firewall-cmd --permanent --add-service=http --add-port=8080/tcp
sudo firewall-cmd --reload
sudo firewall-cmd --list-all

firewalld rejects blocked packets, so a client sees an instant refused or No route to host, not a timeout. Compare --list-all with --permanent --list-all after every change.

4. Open a Port With ufw

On client, deny incoming traffic by default but allow SSH and the API from the lab networks.

Solution
sudo ufw default deny incoming                    # client
sudo ufw allow OpenSSH
sudo ufw allow from 172.16.0.0/16 to any port 8080 proto tcp
sudo ufw enable
sudo ufw status numbered

ufw deny drops silently, so a blocked client times out and [UFW BLOCK] appears in journalctl -k. Delete rules with ufw delete allow ..., not by number, to avoid removing only the IPv6 half.


netfilter on the Router

5. Filter and NAT Forwarded Traffic

On gw, allow only SSH, DNS and the API from lan to dmz, log the rest, then masquerade lan and publish the API on gw:8088.

Solution
sudo nft add table inet lab
sudo nft add chain inet lab forward '{ type filter hook forward priority filter; policy drop; }'
sudo nft add rule inet lab forward ct state established,related accept
sudo nft add rule inet lab forward iifname eth0 oifname eth1 tcp dport '{ 22, 53, 8080 }' accept
sudo nft add rule inet lab forward iifname eth0 oifname eth1 udp dport 53 accept
sudo nft add rule inet lab forward counter log prefix '"lab-drop "' drop
sudo nft add table ip labnat
sudo nft add chain ip labnat postrouting '{ type nat hook postrouting priority srcnat; }'
sudo nft add rule ip labnat postrouting oifname eth1 ip saddr 172.16.0.0/24 masquerade
sudo nft add chain ip labnat prerouting '{ type nat hook prerouting priority dstnat; }'
sudo nft add rule ip labnat prerouting iifname eth0 tcp dport 8088 dnat to 172.16.1.3:8080

From client, port 8080 works and port 9100 times out (lab-drop in journalctl -k on gw). After masquerade, web logs client=172.16.1.2, the router's address; curl http://172.16.0.3:8088/ reaches web through the port forward. sudo nft delete table inet lab; sudo nft delete table ip labnat removes it.


SELinux, Capabilities and Auditing

6. Fix a SELinux File-Label Denial

On web, serve /static/ from a new directory and fix the 403 the way that survives a relabel.

Solution
sudo mkdir -p /data/www; echo hi | sudo tee /data/www/index.html
# add `location /static/ { alias /data/www/; }` to the API server block, reload nginx
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/static/index.html   # 403
sudo ausearch -m AVC -ts recent | grep -o 'avc: .*' | tail -1                       # tcontext default_t/root_t
sudo semanage fcontext -a -t httpd_sys_content_t '/data/www(/.*)?'
sudo restorecon -Rv /data/www
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/static/index.html   # 200

chcon alone is undone by the next restorecon; the semanage fcontext rule is the lasting fix. For a nonstandard port, add semanage port -a -t http_port_t -p tcp <port>, and for a proxy, setsebool -P httpd_can_network_connect on.

7. Give a Service One Capability

On web, run a listener on port 99 as deploy through a systemd unit, granting only CAP_NET_BIND_SERVICE.

Solution
sudo tee /etc/systemd/system/lab-lowport.service <<'EOF'
[Unit]
Description=Lab low-port listener
[Service]
User=deploy
ExecStart=/usr/bin/ncat -lk 99
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
EOF
sudo systemctl daemon-reload
sudo systemctl start lab-lowport
grep -E 'CapEff|CapAmb' /proc/$(systemctl show -p MainPID --value lab-lowport)/status

Without the AmbientCapabilities line, the bind fails with Permission denied, because User= drops all capabilities. capsh --decode=0000000000000400 confirms the one bit is cap_net_bind_service.

8. Record Changes With auditd

On web, watch /etc/passwd and record root commands run by a logged-in user, then read the events.

Solution
sudo tee /etc/audit/rules.d/50-lab.rules <<'EOF'
-w /etc/passwd -p wa -k identity
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=unset -k root-exec
EOF
sudo augenrules --load
sudo useradd audituser                                   # trigger the watch
sudo ausearch -k identity -ts recent --format text
sudo ausearch -k root-exec -ts recent --format text | tail -2

augenrules --load applies the rules without a restart (systemctl restart auditd is refused). The login UID (auid) survives sudo, so ausearch shows the real user behind a root action.


Trust, Integrity and a Sweep

9. Verify a Package Signature

On web, confirm a downloaded package is signed by a trusted key, and see a tampered copy fail.

Solution
dnf download tree
rpm -K tree-*.rpm                                        # digests signatures OK
cp tree-*.rpm bad.rpm; printf 'X' | dd of=bad.rpm bs=1 seek=5000 conv=notrunc status=none
rpm -K bad.rpm; echo "exit=$?"                           # DIGESTS SIGNATURES NOT OK, exit 1
rpm -q gpg-pubkey --qf '%{SUMMARY}\n'                    # the imported keys

gpgcheck=1 in the repo files makes dnf refuse unsigned or altered packages. On Ubuntu the equivalent is a per-repository key under /etc/apt/keyrings/ with a Signed-By line.

10. Serve TLS and Trust the CA

On web, sign a certificate for www.shop.internal with a lab CA and serve it; on client, watch the request fail, then trust the CA.

Solution
# web
sudo openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -noenc \
  -keyout /etc/pki/lab/ca.key -out /etc/pki/lab/ca.crt -days 3650 -subj "/O=Shop Lab/CN=Shop Lab Root CA"
sudo openssl req -new -newkey rsa:2048 -noenc -keyout /etc/pki/lab/www.key -out /tmp/www.csr \
  -subj "/O=Shop Lab/CN=www.shop.internal" -addext "subjectAltName=DNS:www.shop.internal"
sudo openssl x509 -req -in /tmp/www.csr -CA /etc/pki/lab/ca.crt -CAkey /etc/pki/lab/ca.key \
  -CAcreateserial -out /etc/pki/lab/www.crt -days 90 -copy_extensions copy
# serve www.crt/www.key on 8443 with nginx, open the port in firewalld
# client
curl -sS https://www.shop.internal:8443/                 # (60) unable to get local issuer certificate
sudo cp ca.crt /usr/local/share/ca-certificates/shop-lab-root-ca.crt && sudo update-ca-certificates
curl -sS https://www.shop.internal:8443/                 # works

The handshake succeeds before the CA is trusted; only verification fails. On RHEL the trust store is /etc/pki/ca-trust/source/anchors/ with update-ca-trust. A request by IP still fails, because the SAN lists names only.

11. Integrity Baseline and Hardening Sweep

On web, build an AIDE baseline, make a change, and detect it; then run the hardening checks.

Solution
sudo aide --init && sudo cp /var/lib/aide/aide.db.new.gz /var/lib/aide/aide.db.gz
printf '#!/bin/sh\ntrue\n' | sudo tee /usr/sbin/lab-helper; sudo chmod 755 /usr/sbin/lab-helper
sudo aide --check | grep -A2 'Added entries'             # names /usr/sbin/lab-helper
sudo rpm -Va | awk '$1 ~ /5/ && $2 != "c"'               # changed non-config files
awk -F: '$3==0 {print $1}' /etc/passwd                   # only root
sudo find / -xdev -perm -4000 -type f 2>/dev/null        # setuid inventory
sudo lynis audit system --quick                          # on client: hardening index

AIDE reports the added binary; rpm -Va finds changed packaged files; the account, setuid and Lynis checks give a baseline to improve. Keep the AIDE database off the host so an attacker cannot rewrite it.


Cleanup

# web
sudo nft delete table inet lab 2>/dev/null; sudo nft delete table ip labnat 2>/dev/null   # on gw
sudo systemctl disable --now lab-lowport; sudo rm /etc/systemd/system/lab-lowport.service
sudo semanage fcontext -d '/data/www(/.*)?'; sudo rm -rf /data/www /usr/sbin/lab-helper
sudo rm /etc/audit/rules.d/50-lab.rules && sudo augenrules --load
# client
sudo ufw disable

Stopping the playground keeps the state; destroying it removes everything.


Steps verified on Rocky Linux 10.2 and Ubuntu 24.04.4 on iximiuz Labs FlexBox microVMs, kernel 6.1.167, 2026-09.