guest@6admin.io:~$

Egress filtering, the control everyone skips

Almost every firewall I audit is meticulous about what may come in and indifferent about what may go out. That asymmetry is exactly what an attacker counts on, because the valuable moves all happen outbound: the reverse shell dials home, the stolen data leaves, the implant beacons to its C2. An inbound-only firewall is a door with a strong lock and no wall behind it. The point of egress filtering is that a host which has no business talking to the internet should not be able to and a host that does should only reach the few places it genuinely needs.

Why outbound is the attacker’s direction

The reason exfiltration and command-and-control run outbound is not subtle: a connection the inside initiates rides the return path a stateful firewall already allows, so an implant never has to defeat the perimeter, it just has to start the conversation from behind it. That is why “we block inbound” protects nothing against a host that is already compromised and why the channels of choice are the ones always permitted out, HTTPS to anywhere, DNS to any resolver, sometimes ICMP. Each is a tunnel if nothing is watching the direction and the destination.

Default-deny, then an allow-list

The posture is the same one that is obvious for ingress and somehow controversial for egress: deny by default, permit by exception. On a server, that is a few lines and the exceptions are destinations, not just ports:

iptables -P OUTPUT DROP
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

# internal DNS only, and the proxy; nothing else reaches out directly
iptables -A OUTPUT -d 192.168.1.10 -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -d 192.168.1.100 -p tcp --dport 3128 -j ACCEPT

A destination-less rule like “allow 443 to anywhere” is barely egress filtering at all, because every modern C2 speaks 443 to anywhere. The allow-list has to name where, which for a server is a short and knowable set: the package mirror, the time source, the internal proxy, the one API it integrates with.

Most back-end tiers do not need the internet at all and the cleanest control for those is to give them no route to it. A database server with no default gateway cannot exfiltrate over a protocol you forgot to block, because it has nowhere to send the packet:

ip route del default           # the tier reaches its subnets, not the world
ip route add 10.0.0.0/8 via 192.168.1.1

Force DNS and HTTP through choke points

Two protocols need more than a port rule because they are the favourite tunnels.

DNS should resolve only through your resolver and direct DNS to anywhere else, including the encrypted DoH endpoints that let a host slip past your resolver entirely, should be dropped:

iptables -A OUTPUT -p udp --dport 53 ! -d 192.168.1.10 -j DROP
iptables -A OUTPUT -p tcp --dport 53 ! -d 192.168.1.10 -j DROP
iptables -A OUTPUT -d 1.1.1.1 -p tcp --dport 443 -j DROP   # known public DoH
iptables -A OUTPUT -d 8.8.8.8 -p tcp --dport 443 -j DROP

Web traffic goes through a forward proxy that allow-lists destinations, so the firewall rule is “reach the proxy” and the policy lives where it can see URLs rather than just IPs:

# squid: allow a named list, deny the rest
acl allowed dstdomain "/etc/squid/allowed-sites.txt"
http_access allow allowed
http_access deny all

The proxy is also where egress gets its logs: a central record of every destination each host tried to reach, which is the single most useful artifact when something does go wrong.

Detection, honestly

Egress filtering is the control; monitoring is how you learn it is working and where it is too tight or too loose. The signals that matter are a host reaching a destination outside its allow-list, a steady low-volume beacon (the same small request every N minutes is C2, not a user), a large sustained POST or upload from a tier that should only serve and the DNS tunneling shape covered in its own right, long high-entropy labels and a stream of unique subdomains. Flow records or the proxy log carry all of this; the firewall’s own DROP log on the OUTPUT chain is the cheapest version and tells you immediately which host tried to go somewhere it should not.

What I would caution against is reaching for host-level sledgehammers to compensate for a missing network control. Disabling PHP’s file_get_contents, for instance, breaks a large fraction of real applications while barely slowing an attacker who has other ways out; the egress boundary belongs at the network, where it applies to every process on the host regardless of language.

Where to land

Set the OUTPUT policy to DROP and allow by named destination, not by bare port. Give the tiers that do not need the internet no default route, so the question never arises. Force DNS through your resolver and block the DoH bypass, send web traffic through an allow-listing proxy and keep the proxy and firewall DROP logs because they are your exfiltration tripwire. Egress filtering is more work than ingress and catches the half of the attack that ingress never sees: the part where your data, or your shell, is on its way out.

[edit: 2026] The Ansible implementation of all this: the nftables role .