The host has a management allowlist. The guest isn’t on it. A normal connection fails, exactly as it should. Send two preparatory packets first, then connect again, and the guest gets a working TCP socket to the protected service.
An ordinary SYN is dropped by the firewall. A seed creates a conntrack entry, and a forged reverse packet promotes its policy-facing state. The ordinary SYN replay then matches the ESTABLISHED allow rule and reaches the protected service, completing a kernel TCP connection.
This builds on Michael Milvich’s 2024 Anvil Secure research and whitepaper. He demonstrated injection into internal traffic flows from an external interface, including forged replies that make conntrack treat a flow as established. Here, the attacker also creates the initial entry, using a seed that never reaches the protected service. That removes the need to wait for an internal host’s traffic and lets the attacker establish a fresh TCP connection.
For the bridged-host case, the relevant INPUT policy reduces to this. The guest is outside the management subnet, 192.0.2.0/24, and bridge netfilter is enabled:
iptables -P INPUT DROP
iptables -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
iptables -A INPUT -s 192.0.2.0/24 -p tcp \
-m multiport --dports 22,8006 -j ACCEPT
An ordinary SYN to port 22 or 8006 misses both accepts and hits the default DROP. After the preparatory packets, the SYN matches the first accept. The management source check below it never runs.
In my tests, that guest completed six connections and delivered application data. The ESTABLISHED,RELATED rule accepted 32 packets. Every management-allowlist rule stayed at zero. The rule meant to keep the guest out never got to decide.
A pod reached a blocked host listener too, without privileged mode or host networking. Its default capability set included CAP_NET_RAW, which was enough to send the crafted frames. The ordinary connection failed. Remove either preparatory stage and it failed again.
This is conntrack state laundering. An attacker uses packets that never reach the service to change what the firewall believes about a connection. The next packet gets waved through as established, before the service has seen its first SYN.
If you rely on the host firewall to keep guests away from SSH, a management UI, or a database listening on every interface, this is the boundary being crossed. A foothold in the workload becomes a way to talk to services on the host.
I’m publishing ctmap alongside this research to find those paths and use them. It compares an ordinary connection with the laundering sequence, checks that the preparatory packets caused the difference, and can expose the resulting TCP connection to ordinary clients such as SSH and curl.
The attacker needs to deliver crafted Ethernet frames to the target, from a suitably capable guest, container, or adjacent machine. The target needs a stateful accept rule and a packet path that lets the attacker plant useful state. Those conditions matter more than the DROP at the bottom of the ruleset.
Drop the packet, keep the state
A stateful firewall needs a memory. Otherwise every packet in an SSH session would have to qualify as a new connection. Linux keeps that memory in conntrack: which flow a packet belongs to, which direction it is traveling, and whether a reply has been seen. An ESTABLISHED,RELATED accept rule trusts that record and lets traffic through before checking the rules for new connections.
It is tempting to read established as already approved. Conntrack has a narrower job: record what the packet path has shown it. That record can change before Linux decides to discard the packet. Dropping it later doesn’t necessarily undo the change.
The sequence at the top uses that surviving state in three stages:
- A seed leaves a confirmed flow entry, despite never reaching the protected listener.
- A forged reverse packet makes the tracker record a reply, then dies in later processing.
- An ordinary packet matches the altered entry and passes an established-state accept rule.
The trick depends on the seed and replay looking like the same flow to conntrack while taking different paths through the receive code. They share the same IP addresses and transport ports. Their Ethernet destinations can differ because the destination MAC isn’t part of conntrack’s flow key. The lookup is scoped by network namespace and conntrack zone; the seed’s interface, MAC, and TTL aren’t part of that identity.
For the replay traveling in the original direction, the decisive flag is IPS_SEEN_REPLY. It says the tracker has seen traffic in the reverse direction. It doesn’t say that a socket completed a handshake. The firewall’s ESTABLISHED classification is also separate from conntrack’s internal TCP state. resolve_normal_ct and nf_conntrack_in show how direction and reply-seen feed that classification.
Forge the reply convincingly enough for the tracker, and the firewall can admit the first SYN as established. TCP is only about to meet this connection. The firewall thinks it knows it already.
Two packets buy the third one admission
The direct form adds an overly broad multicast accept above the management allowlist:
iptables -I INPUT 2 -m pkttype --pkt-type multicast -j ACCEPT
A rule intended to let discovery traffic through checks the Ethernet packet type, but fails to constrain the IP destination, protocol, and port. That leaves room for a TCP SYN addressed to the protected service, wrapped in a multicast-addressed Ethernet frame.
Only the Ethernet destination is multicast. The IP destination is the target’s unicast address; the TCP destination is the protected port. Change the IP destination and you plant state for the wrong connection.
The multicast rule accepts the seed, and conntrack confirms the entry at the end of the IP input hooks. TCP gets its turn afterward. tcp_v4_rcv sees a packet type other than PACKET_HOST and discards it before looking up a socket. No SYN reaches the listener. No reset comes back to tear anything down. Conntrack keeps the confirmed entry.
The second packet pretends to be the service answering. Its source is the target’s service address and port; its destination is the guest’s address and port. Conntrack processes it in PREROUTING and, if the protocol tracker accepts it, sets reply-seen. Routing checks can kill the forged reply afterward. By then it has done its job.
Now send the SYN in an ordinary frame addressed to the target. Same flow, different Ethernet destination. Conntrack finds the entry and reports ESTABLISHED, so the established accept rule lets it past the management allowlist. TCP finally receives a normally addressed SYN and answers it. The client completes the handshake.
That explains the zero counters. The allowlist wasn’t consulted because an earlier rule had already accepted the packet. Adding a drop below that accept won’t help, and an INVALID drop has nothing to say about a packet classified as established.
The bridge version doesn’t need the multicast accept
At this point, removing the broad multicast rule looks like the fix. It closes the direct form. On a Linux bridge, though, the seed can get its entry confirmed before INPUT has any say at all.
With br_netfilter active and the corresponding bridge-nf-call-iptables or IPv6 setting enabled, bridged IP traffic also passes through IP netfilter hooks. That creates confirmation opportunities outside the host’s INPUT rule walk.
For a group-addressed seed, a forwarded copy can confirm the shared entry at POSTROUTING. The bridge’s local-input hook can also explicitly confirm an unconfirmed entry before handing the frame up to IP. The comment above br_nf_local_in explains why that fallback exists. Either path can leave useful state behind when INPUT drops the local copy.
A group-addressed seed enters a Linux bridge and reaches conntrack. A forwarded copy may confirm the entry at POSTROUTING; the local path can confirm it at bridge LOCAL_IN before IP INPUT drops the seed. Either confirmation path can leave the entry needed for forged-reply promotion and an admitted ordinary replay.
INPUT can count the drop correctly while the next packet gets in. The drop counter proves that one packet died. It doesn’t prove that the packet left nothing behind.
The unicast variants go further. Address a seed to another bridge port’s local MAC and it can be confirmed, then discarded by IP input sanity checks before INPUT. An unknown-unicast seed can be confirmed while being forwarded, without local delivery at all. There is no INPUT rule you can write for a packet that never reaches INPUT.
In my Linux 6.8 bridge tests, the complete group-addressed, local-FDB, and unknown-unicast sequences connected with bridge filtering enabled. Disable that handoff and they stopped connecting. Ordinary connections failed in both modes. A FORWARD drop alone left other confirmation paths available.
A ruleset doesn’t show you where the bridge committed the entry. You need the bridge topology, the active hooks, and the conntrack zones as well. The host can have a sensible-looking default-deny policy while its bridge helps manufacture the state that policy trusts.
Find the blocked ports that still connect
A normal port scan sees the failed connection and moves on. It can give you the same result before and after you fix this bypass. ctmap tries the state manipulation before making the connection, then takes the sequence apart to check what made it work.
For a TCP seed scan, the comparison is:
| Attempt | Required result |
|---|---|
| Ordinary connection | Blocked |
| Complete seed, promotion, and replay | Connected |
| Promotion and replay, without seed | Blocked |
| Seed and replay, without promotion | Blocked |
| Seed sent to the target’s ordinary MAC | Blocked |
Every control uses a fresh tuple so it can’t borrow state from the successful attempt. If the ordinary connection works, the port is open; laundering gets no credit. If a required control also reaches it, the result is inconclusive. LAUNDERED means the selected variant produced its positive observation and the required controls didn’t. The TCP scan engine records those decisions explicitly.
A reset only after the complete sequence is useful too: launder-refused means the path became reachable, even though no listener completed the connection. filtered means no response under that run’s timing and profile.
Run a scan and save the observations:
sudo ./ctmap scan 192.0.2.10 -p 22 \
--variant CTL-TCP-GROUP-SYN \
--evidence-out runs/host-22.json --json
Use a new evidence path for each run. The file keeps the baseline, complete sequence, controls, preflight, cleanup, and limitations together, so you can inspect why a port earned its verdict.
The variant registry includes non-TCP mechanisms too. A quoted ICMP error, an echo reply, and an SCTP response answer different reachability questions; each variant names the response it requires and the operations implemented.
For supported TCP variants, ctmap’s relay opens a local listener, performs the laundered connection setup, and passes bytes through the resulting kernel TCP socket. Point an SSH client at the local port and it speaks SSH to the target. Use curl and it speaks HTTP. Neither client needs to know how the connection got through the firewall.
The unusual packet work ends at connection setup. After that, the attacker has a normal byte stream to a service the host policy excluded them from.
Stop it before the tracker believes it
Moving the same drop rule around below ESTABLISHED doesn’t repair this. You have to stop the seed, prevent the forged reply from changing state, or change the admission rule that trusts that state.
For the direct form, make discovery accepts describe discovery traffic: constrain the IP destination, protocol, and port. Accepting every frame with a multicast Ethernet destination admits much more than that. The bridge confirmation paths still need separate treatment.
For the forged reply, reject inbound packets claiming one of the host’s own source addresses before conntrack processes them. Raw-priority anti-spoofing can prevent the promotion; INPUT is too late for that mutation. Selective notrack before conntrack can also prevent the unwanted entry, provided the policy handles untracked traffic correctly.
Where the network doesn’t need the bridge netfilter handoff, disable it. Where it does, test filtering or tracking controls earlier in the actual packet path. A rising drop counter farther along that path can coexist with a successful bypass.
Dropping CAP_NET_RAW removes the container’s raw-frame injection capability used here. The pod in my tests had it without being privileged. Guests controlling their own kernels and separate physical peers need enforcement outside that container capability policy.
Then run the complete sequence and its controls again. The useful result is a connection that used to open and now fails, with the ordinary baseline still blocked. A prettier ruleset doesn’t establish that.
Earlier work
Cloudflare’s 2021 investigation also showed conntrack confirmation preceding a later drop. That ordering matters here: the seed can leave a confirmed entry even though it never reaches the protected listener.