When stopping containerized services or proxies (e.g., via podman stop / systemctl stop), we observe a race condition where incoming packets on mapped host ports are DNAT'd and forwarded to the host's default gateway on the physical egress interface (eth0), rather than being dropped or rejected locally.
In strict enterprise network environments using SDN switches with active dataplane endpoint learning, receiving packets on the physical network with internal container destination IPs (e.g., 10.89.0.x or RFC 1918 private subnets) triggers incorrect endpoint learning and routing table corruption, leading to network outages.
Root Cause Analysis in Netavark
Inspecting src/network/bridge.rs reveals two flaws in the teardown() function lifecycle:
1. Inverted Teardown Order (FIFO instead of LIFO)
teardown() calls remove_link() before self.teardown_firewall():
Rust
// 1. Link and container routes are deleted FIRST
let complete_teardown = match remove_link(...) { ... };
// 2. Firewall / DNAT / Port-forwarding rules are deleted LAST
match self.teardown_firewall(complete_teardown, bridge_name) { ... }
Consequence:
remove_link() destroys the virtual veth/bridge interface and removes the host route to the internal container subnet (10.89.0.0/24).
During the time window before teardown_firewall() is reached, DNAT rules remain active in the kernel.
Incoming client packets (e.g., from 192.0.2.100) arriving on the host port are translated to the internal container IP (10.89.0.2).
Since the container interface and route no longer exist, the Linux kernel falls back to the host's default route (eth0), spitting the translated packet out to the physical external network.
2. Error Handling & Dangling Rules
If remove_link() returns an error, complete_teardown is set to false. This prevents teardown_firewall() from running properly, leaving DNAT rules active indefinitely without a backing network interface.
Solution
A DNAT rule must never outlive the route to its target. We would like to discuss the best approach to resolve this upstream:
- Re-ordering Teardown (LIFO): Can the teardown process be split or re-ordered so that container-specific DNAT/port-forwarding rules are removed BEFORE remove_link() destroys the internal routes?
- Conntrack Flush: Should Netavark explicitly flush conntrack entries associated with the container IP during teardown to avoid existing connections lingering through the transition?
- Fallback Handling: What is the recommended way to handle errors in remove_link() to ensure firewall cleanup is never skipped?
Thanks for your feedback
When stopping containerized services or proxies (e.g., via podman stop / systemctl stop), we observe a race condition where incoming packets on mapped host ports are DNAT'd and forwarded to the host's default gateway on the physical egress interface (eth0), rather than being dropped or rejected locally.
In strict enterprise network environments using SDN switches with active dataplane endpoint learning, receiving packets on the physical network with internal container destination IPs (e.g., 10.89.0.x or RFC 1918 private subnets) triggers incorrect endpoint learning and routing table corruption, leading to network outages.
Root Cause Analysis in Netavark
Inspecting src/network/bridge.rs reveals two flaws in the teardown() function lifecycle:
1. Inverted Teardown Order (FIFO instead of LIFO)
teardown() calls remove_link() before self.teardown_firewall():
Rust
Consequence:
remove_link() destroys the virtual veth/bridge interface and removes the host route to the internal container subnet (10.89.0.0/24).
During the time window before teardown_firewall() is reached, DNAT rules remain active in the kernel.
Incoming client packets (e.g., from 192.0.2.100) arriving on the host port are translated to the internal container IP (10.89.0.2).
Since the container interface and route no longer exist, the Linux kernel falls back to the host's default route (eth0), spitting the translated packet out to the physical external network.
2. Error Handling & Dangling Rules
If remove_link() returns an error, complete_teardown is set to false. This prevents teardown_firewall() from running properly, leaving DNAT rules active indefinitely without a backing network interface.
Solution
A DNAT rule must never outlive the route to its target. We would like to discuss the best approach to resolve this upstream:
Thanks for your feedback