Lab 02: The Hardened Subnet That Broke Everything
A security review tightened the data tier NACL to permit only the database port. Inbound looks correct, the security groups allow the traffic, both hosts are healthy — and every connection now hangs. Neither host can see why.
- Debugging time
- ~25 min
- Reading time
- 14 min
- Reported by
- Data Platform
- Tier
- Associate
Reporting service cannot reach the database after a NACL hardening change
Reported by Data Platform
- Environment
- staging
- Region
- us-east-1
- Client subnet
- 10.0.0.0/24 (app-a)
- Target subnet
- 10.0.11.0/24 (data-a)
- Change ref
- CHG-2291 — NACL tightening
Security review flagged that the data tier subnet had a permissive network ACL. We shipped CHG-2291 last night to restrict it to just the database port.
Since then the reporting service cannot connect. It hangs on connect and times out. We have not changed the application, the security groups, or any routing.
What we verified before escalating:
- The data tier NACL explicitly allows inbound TCP 5432 from the VPC CIDR. That is the port.
- The database security group allows 5432 from the app tier security group.
- The app tier security group allows all outbound.
- Both instances are running and both subnets are in the same VPC and AZ.
- The database process is listening —
ss -lntpon it showed0.0.0.0:5432before we lost access.
We are being asked to roll back CHG-2291, but nobody can explain what is actually wrong with it, and the security finding is legitimate. We would rather understand it than revert blindly.
What you are working with
Two subnets in one VPC. No NAT Gateway, no internet gateway on the data side, no Transit Gateway. The failing flow never leaves the VPC.
The connection under investigation is the local one between the two subnets. Only the data subnet has an explicit network ACL, and both of its rules name the service port.
- EC2
Reporting service
i-… in subnet-app-a (10.0.0.0/24)
- FILTER
App subnet NACL — outbound
allow all
- RTB
VPC local route
10.0.0.0/16 → local
- FILTER
Data subnet NACL — inbound
rule 100: allow tcp 5432 from 10.0.0.0/16
- FILTER
Database security group
allow 5432 from sg-app
- DEST
Database host
10.0.11.20:5432 — listening
The outbound path is clean and every check on the ticket confirms it. The request arrives. Trace the reply instead.
Scope and constraints
- In scope: network ACLs, security groups, and the difference between them.
- Out of scope: the database itself, DNS, routing, and the application. The listener works and accepts connections.
- The security finding behind CHG-2291 is valid. Reverting to allow-all is not the answer — find a configuration that is both tight and correct.
- You get a shell on the app tier host only. The data host has no route off the VPC, so Session Manager cannot reach it. That constraint is deliberate and it is part of the lesson.
Deploy the broken state
Download the file below into an empty directory and apply it. Two t3.micro instances, no NAT
Gateway, no interface endpoints — this lab is deliberately cheap to run.
terraform init
terraform apply
# Shell on the app tier
terraform output -raw start_session_command
# The target
terraform output -raw data_private_ipFull source: main.tf. It provisions the VPC, both
subnets and their NACLs, both security groups, VPC Flow Logs to CloudWatch, and two instances. The
data host runs a small Python TCP listener on 5432 — Python because that subnet has no egress to
install anything with.
Allow about two minutes after apply for the listener to start and for flow log records to begin appearing. Flow logs aggregate on a 60-second interval here, so the first records are not instant.
Open a shell on the app tier host:
aws ssm start-session --region us-east-1 --target "$(terraform output -raw app_instance_id)"Set the target once so the rest of the commands are copy-paste:
DB=10.0.11.20 # use: terraform output -raw data_private_ip
PORT=5432Confirm the failure
nc -vz -w 8 "$DB" "$PORT"
# Ncat: Version 7.93 ( https://nmap.org/ncat )
# Ncat: Connection timed out.A timeout, not a refusal. Nothing rejected the connection — it went unanswered.
Worth contrasting against a port that is genuinely closed, so you can feel the difference:
# Nothing is listening on 9999, and the NACL denies it inbound too.
nc -vz -w 8 "$DB" 9999
# Ncat: Connection timed out.Both time out, which tells you the NACL is dropping rather than rejecting. A security group or NACL
deny silently discards; it never sends a RST.
Watch the conversation from the client
This is the step that narrows it:
sudo tcpdump -ni any "host $DB and tcp port $PORT" &
nc -vz -w 8 "$DB" "$PORT"
# 14:31:02.118 IP 10.0.0.50.41288 > 10.0.11.20.5432: Flags [S], seq 2846...
# 14:31:03.121 IP 10.0.0.50.41288 > 10.0.11.20.5432: Flags [S], seq 2846...
# 14:31:05.129 IP 10.0.0.50.41288 > 10.0.11.20.5432: Flags [S], seq 2846...SYNs going out, nothing coming back. Note the client's source port: 41288. Hold onto that.
Rule out the security groups
Confirm the ticket's claim rather than trusting it:
aws ec2 describe-security-groups \
--filters "Name=tag:Lab,Values=02-nacl-return-path" \
--query 'SecurityGroups[].{Name:GroupName,In:IpPermissions[].FromPort,Out:IpPermissionsEgress[].IpProtocol}' \
--output tableThe database group permits 5432 from the app group, and both permit all egress. Security groups are stateful, so an allowed outbound connection has its return traffic permitted automatically. No inbound rule is needed on the client for the reply. The ticket was right.
Read the network ACL
aws ec2 describe-network-acls \
--network-acl-ids "$(terraform output -raw data_network_acl_id)" \
--query 'NetworkAcls[0].Entries[].[RuleNumber,Egress,Protocol,PortRange.From,PortRange.To,CidrBlock,RuleAction]' \
--output tableYou will get something like this. Egress: True means an outbound rule:
| Rule | Egress | Proto | Ports | CIDR | Action |
|---|---|---|---|---|---|
| 100 | False | tcp (6) | 5432–5432 | 10.0.0.0/16 | allow |
| 32767 | False | all (-1) | all | 0.0.0.0/0 | deny |
| 100 | True | tcp (6) | 5432–5432 | 10.0.0.0/16 | allow |
| 32767 | True | all (-1) | all | 0.0.0.0/0 | deny |
Read the outbound rules against the source port you noted from tcpdump.
Get the proof from VPC Flow Logs
Neither host can show you a NACL decision — the drop happens at the subnet boundary, above the instance. Flow logs are the only place it surfaces.
aws logs filter-log-events \
--log-group-name "$(terraform output -raw flow_log_group)" \
--start-time "$(( ($(date +%s) - 600) * 1000 ))" \
--filter-pattern '"5432"' \
--query 'events[].message' --output text | tail -20The format is srcaddr srcport dstaddr dstport protocol packets action log-status. Look for two
records describing the same connection, and compare their actions.
Sign in to read the debrief
This debrief is free — it just needs an account. One covers every free lab, on any device, and there is no card and no trial.
Solve it yourself first if you can. The brief, the reproduction steps, and the lab’s Terraform are all open and need no account.