Module 11 — Firewall Rules (Attack / Understand / Fix)
Objective
Demonstrate that any VM can directly access PostgreSQL and the Go backend, understand why network segmentation matters, then configure UFW firewall rules on all three VMs.
By the end of this module you will have:
| Outcome | Detail |
|---|---|
| Exploited | Connected directly to PostgreSQL from web-server, bypassing the app |
| Understood | Why unrestricted network access between VMs is dangerous |
| Fixed | UFW firewall rules on all 3 VMs restricting port access |
| Verified | web-server can no longer reach PostgreSQL; app still works end-to-end |
Prerequisites
- Module 10 complete (brute force protection added)
- App running and accessible via Nginx at http://192.168.56.13
- SSH access to all three VMs:
ssh db-server,ssh app-server,ssh web-server
1. Introduction — The network is flat
You have three VMs on the same network (192.168.56.0/24). Right now, every VM can reach every port on every other VM. There are no firewalls, no access controls, and no segmentation.
This means if an attacker compromises one VM — say, the web-server through an Nginx vulnerability — they have direct access to:
- The Go backend on app-server (port 8080)
- The PostgreSQL database on db-server (port 5432)
- SSH on all machines (port 22)
In production, the web-server should only talk to the app-server, and the app-server should only talk to the database. The web-server should never touch the database directly.
2. CTF Challenge
Challenge: From web-server, connect directly to the PostgreSQL database on db-server. You know the database credentials from the Go source code. Can you read, modify, and delete data without going through the application? You have 20 minutes.
Rules:
- SSH to web-server only
- Use only tools available on web-server (or that you install)
- Your goal: read all customer data and all user credentials directly from the database
If you get stuck, reveal the hints below one at a time:
Hint 1
You need the PostgreSQL client. Install it with sudo apt install -y postgresql-client.
Hint 2
The database connection string is in the Go source code. Look for the host, port, username, password, and database name.
Hint 3
psql -U appuser -d customerdb -h 192.168.56.11
Once you have tried the challenge (or after 20 minutes), continue to the guided attack below.
3. Guided Attack — Direct database access from web-server
Step 1 — Install PostgreSQL client on web-server
ssh web-server
sudo apt install -y postgresql-client
Step 2 — Connect to the database directly
The database credentials are hardcoded in the Go source code (main.go). Use them to connect from web-server:
psql -U appuser -d customerdb -h 192.168.56.11
Enter the password when prompted: apppassword123
You are now connected directly to PostgreSQL from a machine that should have no business talking to the database.
Step 3 — Read all data
-- Dump all customers
SELECT * FROM customers;
-- Dump all users (including password hashes)
SELECT * FROM users;
You can see every customer record and every user account. Even though passwords are now bcrypt hashes (from Module 10), an attacker with database access can:
- Read all customer PII
- Attempt offline hash cracking
- Modify data without any audit trail
Step 4 — Modify data directly
-- Create a backdoor admin account
INSERT INTO users (username, password) VALUES ('backdoor', '$2a$10$InvalidHashButItProvesTHePoint');
-- Delete all customer records
DELETE FROM customers;
-- Or worse — drop the entire table
-- DROP TABLE customers; (don't actually run this)
Step 5 — Exit and reflect
\q
You just demonstrated that the web-server — which should only serve HTTP traffic — has full, unrestricted access to the database. A single compromised Nginx module, a misconfigured CGI script, or a reverse shell on the web-server gives an attacker everything.
4. Bonus Attack — Direct backend access
The web-server is supposed to proxy requests through Nginx. But you can also call the Go backend directly:
# From web-server, call the Go backend directly on port 8080
curl http://192.168.56.12:8080/health
And from your Mac, you can bypass Nginx entirely:
# From Mac, access the Go backend directly (skipping Nginx)
curl http://192.168.56.12:8080/health
This means any security controls you add to Nginx (rate limiting, WAF rules, CORS headers) can be bypassed by going straight to the backend.
5. Why This Works
No firewall = no boundaries
| Source | Destination | Port | Should be allowed? | Currently allowed? |
|---|---|---|---|---|
| web-server | db-server | 5432 | No | Yes |
| web-server | app-server | 8080 | Yes | Yes |
| Mac (host) | db-server | 5432 | No | Yes |
| Mac (host) | app-server | 8080 | No | Yes |
| app-server | db-server | 5432 | Yes | Yes |
| Any VM | Any VM | 22 | Yes (SSH) | Yes |
Without firewalls, PostgreSQL's pg_hba.conf is the only access control — and it allows the entire 192.168.56.0/24 subnet. Any machine on the network can connect.
The principle of least privilege
Each server should only be able to reach the services it needs:
- web-server needs: Nginx (port 80/443), outbound to app-server (port 8080), SSH
- app-server needs: Go backend (port 8080 from web-server only), outbound to db-server (port 5432), SSH
- db-server needs: PostgreSQL (port 5432 from app-server only), SSH
Everything else should be denied.
6. The Fix — UFW firewall rules
UFW (Uncomplicated Firewall) is a frontend for iptables that makes it easy to manage firewall rules on Ubuntu.
Fix db-server — Only app-server can reach PostgreSQL
ssh db-server
# Set default policies
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow PostgreSQL ONLY from app-server
sudo ufw allow from 192.168.56.12 to any port 5432 comment 'app-server -> PostgreSQL'
# Allow SSH from the Host-Only network (so you can still manage the VMs)
sudo ufw allow from 192.168.56.0/24 to any port 22 comment 'SSH from internal network'
# Enable the firewall
sudo ufw enable
When prompted "Command may disrupt existing ssh connections. Proceed with operation?" type y.
Verify:
sudo ufw status verbose
Expected output:
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To Action From
-- ------ ----
5432 ALLOW IN 192.168.56.12 # app-server -> PostgreSQL
22 ALLOW IN 192.168.56.0/24 # SSH from internal network
Fix app-server — Only web-server can reach the Go backend
ssh app-server
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow Go backend ONLY from web-server
sudo ufw allow from 192.168.56.13 to any port 8080 comment 'web-server -> Go backend'
# Allow SSH from the Host-Only network
sudo ufw allow from 192.168.56.0/24 to any port 22 comment 'SSH from internal network'
sudo ufw enable
Verify:
sudo ufw status verbose
Fix web-server — Accept HTTP/HTTPS from anywhere, SSH from internal
ssh web-server
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow HTTP and HTTPS from anywhere (for Nginx and Tailscale Funnel)
sudo ufw allow 80/tcp comment 'HTTP'
sudo ufw allow 443/tcp comment 'HTTPS'
# Allow SSH from the Host-Only network
sudo ufw allow from 192.168.56.0/24 to any port 22 comment 'SSH from internal network'
sudo ufw enable
Verify:
sudo ufw status verbose
Tighten pg_hba.conf — Restrict to app-server only
The firewall blocks network connections, but you should also restrict PostgreSQL's own access control as defense in depth.
ssh db-server
sudo nano /etc/postgresql/16/main/pg_hba.conf
Find the line you added in Module 02:
host all all 192.168.56.0/24 md5
Replace it with:
host all all 192.168.56.12/32 md5
This restricts PostgreSQL to accept connections only from app-server's exact IP address.
Restart PostgreSQL:
sudo systemctl restart postgresql
7. Verify — Re-run the attacks
Verify direct database access from web-server is blocked
ssh web-server
psql -U appuser -d customerdb -h 192.168.56.11
Expected: The connection hangs for a few seconds, then fails with a timeout or "connection refused" error. The firewall on db-server drops the packet because it did not come from 192.168.56.12.
Verify direct backend access from Mac is blocked
curl --connect-timeout 5 http://192.168.56.12:8080/health
Expected: Connection timeout. The firewall on app-server only allows port 8080 from web-server (192.168.56.13).
Verify the app still works end-to-end
# Access through Nginx (the correct path)
curl http://192.168.56.13/health
Expected: {"status":"healthy","database":"connected"}. The full chain works:
Mac -> web-server:80 (Nginx) -> app-server:8080 (Go) -> db-server:5432 (PostgreSQL)
Each hop is allowed by the firewall rules. Direct shortcuts are blocked.
Verify from app-server to db-server still works
ssh app-server
psql -U appuser -d customerdb -h 192.168.56.11 -c "SELECT 1;"
Expected: Returns 1. app-server is explicitly allowed to reach PostgreSQL.
Check firewall status on all VMs
ssh db-server 'sudo ufw status numbered'
ssh app-server 'sudo ufw status numbered'
ssh web-server 'sudo ufw status numbered'
Troubleshooting
| Problem | Solution |
|---|---|
| Locked out of SSH | The UFW rules allow SSH from 192.168.56.0/24. If locked out, access the VM console directly through VirtualBox and run sudo ufw disable. |
| App returns 502 Bad Gateway | The firewall on app-server may be blocking Nginx. Verify: sudo ufw status on app-server shows port 8080 allowed from 192.168.56.13. |
| Database connection fails from app-server | Check both UFW on db-server (sudo ufw status) and pg_hba.conf (should have 192.168.56.12/32). Restart PostgreSQL after pg_hba.conf changes. |
ufw: command not found | Install it: sudo apt install -y ufw |
| UFW shows "inactive" after reboot | Enable persistence: sudo systemctl enable ufw |
Summary
| What you did | What you learned |
|---|---|
| Connected to PostgreSQL from web-server | Without firewalls, every VM can reach every service |
| Read/modified data bypassing the app | Direct database access has no application-level security |
| Called Go backend directly, skipping Nginx | Nginx security controls are useless if the backend is directly accessible |
| Configured UFW on all 3 VMs | Each server now only accepts traffic from authorized sources |
| Tightened pg_hba.conf | Defense in depth — multiple layers of access control |
The one rule to remember: Every server should accept only the traffic it needs, from only the sources that should send it. Default deny, explicit allow.