Cloud Infrastructure · Cloud Networking

Network ACLs and Stateless Filtering at the Subnet Boundary

Why an ordered, stateless rule list needs an explicit rule for return traffic, how ephemeral port ranges make that rule hard to write, and where the ACL is evaluated relative to the interface.

A network access control list is the older, blunter filtering mechanism in a virtual network, and it exists because interface-level filtering cannot express everything a network operator needs. It attaches to a subnet rather than an interface, keeps no state, and evaluates rules in order with a first-match decision. Each of those three properties is a deliberate trade against the stateful, unordered, interface-attached alternative, and each produces a characteristic failure.

Order matters, and the default rule ends the list

The list is evaluated from the lowest rule number upward. The first rule whose criteria match the packet decides: allow or deny, and evaluation stops. Every list ends with an implicit deny for both directions, so a packet that matches nothing is dropped.

This is the model every traditional firewall uses, and its failure mode is the one every operator has met: a narrow deny placed after a broad allow is unreachable and has no effect. The narrow rule must be numbered lower than the broad one, and the numbering is explicit rather than positional, so inserting a rule requires choosing a number in the gap between two existing ones. Numbering conventions that step by ten or by hundred exist precisely to leave room for insertion.

The difference from an interface-level group is not cosmetic. A group that permits a wide range and a narrow range permits both, with no ordering question. An ACL that permits the wide range at rule 100 and denies part of it at rule 200 permits the whole range, because rule 100 matched first. Getting a deny to take effect requires it to precede every permit that covers the same traffic, which means the permit must be narrowed instead. The two mechanisms answer the same question with different semantics, and a rule set translated from one to the other without accounting for that will not behave the same way.

No state means every direction needs its own rules

The ACL is evaluated per packet with no memory. A packet leaving the subnet is matched against the outbound list; a packet arriving is matched against the inbound list. Nothing links the two, so the reply to a permitted outbound connection must be permitted by an inbound rule of its own.

The reply’s addressing is the difficulty. A client connecting outward from the subnet uses a source port drawn from the ephemeral range, and the server’s reply is addressed to that port. The ACL therefore has to permit inbound traffic to the whole ephemeral range from the server’s address, and outbound traffic from the whole ephemeral range to the server’s port. The ranges are wide and the rules are coarse.

The ephemeral range on Linux defaults to 32768 through 60999, a span of 28,232 ports, and it can be changed through a sysctl. Other systems use different ranges: the BSD-derived default is often 49152 through 65535, which is the range IANA suggests for dynamic allocation, and Windows uses 49152 through 65535 as well. An ACL written for one operating system’s range will silently break connections from hosts using another, and the breakage appears as a connection that establishes but never receives a reply.

import ipaddress

EPHEMERAL = (32768, 60999)          # Linux default; differs by platform
SUBNET = ipaddress.ip_network("10.20.4.0/24")

def rules_for_outbound_https(peer):
    """Two ACL entries: the request out, the reply back in."""
    return [
        # outbound: client port -> 443
        {"dir": "out", "proto": "tcp", "src": str(SUBNET),
         "dst": str(peer), "ports": (443, 443), "action": "allow"},
        # inbound: 443 -> ephemeral, evaluated independently of the above
        {"dir": "in", "proto": "tcp", "src": str(peer),
         "dst": str(SUBNET), "ports": EPHEMERAL, "action": "allow"},
    ]

The second rule is the one that is usually forgotten, and it is forgotten because the stateful mechanism makes it unnecessary. A subnet whose ACL permits outbound 443 and nothing inbound will complete a TCP handshake only if the handshake’s return packets happen to be permitted by something else. In practice the SYN leaves, the SYN-ACK is dropped, and the client retries until it gives up.

The ACL sees the packet before translation

An ACL attached to a subnet is evaluated on packets entering or leaving that subnet, and the address it sees is the one on the interface. That matters most for traffic that is about to be translated by a NAT gateway, because the translation happens after the packet has left the subnet and been matched. The ACL therefore must be written in terms of the private address, not the public one that the outside world will see.

For inbound traffic through a NAT that performs port forwarding, the situation reverses and becomes genuinely confusing. The packet arrives from the internet addressed to the public address, is translated to a private address, and then enters the subnet. Whether the ACL is evaluated before or after translation determines which address the rule must name, and the two orders produce rules that look almost identical but behave differently. Where translation occurs at a gateway outside the subnet, the ACL sees post-translation addresses on the way in and pre-translation addresses on the way out, so a rule set that is symmetric in appearance is asymmetric in effect.

The safe practice is to determine the evaluation point from the documentation and to write rules against the addresses that exist at that point. Internet gateway, NAT, and source address translation works through the ordering of translation and filtering in the outbound path, and the ordering is not the same in both directions.

Why an operator would choose this

Given the extra work, the stateless subnet filter has properties the stateful interface filter cannot provide.

It applies to everything in the subnet, including interfaces that have no group attached and including traffic that is not addressed to an instance at all. A subnet with a broad allow at the interface level and a narrow ACL at the subnet level has defence in depth in the literal sense: two independent decisions, made by different mechanisms at different points, both of which must permit the packet.

It can express a deny that has priority, which the set-based mechanism cannot. Blocking a specific source address while permitting its neighbours is a single low-numbered deny in an ACL, and requires enumerating the neighbours in a group. For a blocklist that changes often, the ACL is the natural place for it.

It costs no state. There is no table to fill, no idle timeout to tune, and no difference between a flow that has been open for a week and one that started a millisecond ago. A host that opens a million connections consumes nothing at the ACL, while the same host would exhaust an interface-level tracker, whose capacity limit is described in security groups and the stateful connection tracker. For a subnet that fronts a workload with extreme connection churn, the stateless mechanism scales where the stateful one does not.

The trade is symmetric: no state means no ability to distinguish a solicited reply from an unsolicited packet. Any packet matching the inbound rule is permitted, whether or not anything inside ever sent a request. An ACL rule that permits inbound to the ephemeral range from a specific peer permits that peer to initiate connections into the subnet on any ephemeral port, which is a much wider permission than it appears to be. That is not a defect in the ACL; it is what stateless means, and it is why the two mechanisms are usually deployed together rather than as alternatives.

Rule count and the cost of enumeration

Both mechanisms have limits on the number of rules, and the ACL’s are lower because each entry is evaluated in order and the list is replicated to every interface in the subnet. A default ACL permits all traffic in both directions, which is a reasonable starting point for a subnet whose interfaces carry groups, and a restrictive ACL replaces that with an explicit enumeration.

Enumeration grows with the number of peers and services, and the growth is multiplicative when both are variable: a subnet that talks to ten peers on four ports needs forty outbound entries and forty inbound entries before any deny is added. The per-list limit is reached in designs that treat the ACL as the primary policy mechanism rather than as a coarse boundary, which is the argument for keeping the ACL coarse and putting the detail in the interface groups.

The evaluation cost is also not free, though it is small. Each packet is matched against rules in order until one matches, so a long list with the useful rules near the bottom costs more per packet than one with them near the top. Because the list is per-subnet, the cost is paid by every packet in the subnet, and a subnet carrying a high packet rate with a long list is a place where rule ordering has a measurable effect even though the individual comparisons are cheap.

Failure modes that look like something else

The characteristic ACL failures all present as silence.

A missing inbound rule for return traffic makes connections hang after the handshake, which is indistinguishable from an application that is not listening on the port. A deny numbered above a broad allow has no effect, and the traffic it was meant to block continues, which looks like the ACL was not applied at all. A rule written against the public address of a NAT rather than the private one inside the subnet matches nothing, so the intended restriction is absent while the ACL appears correctly configured. An ephemeral range narrower than the platform’s actual range permits some connections and refuses others from the same host, which looks like intermittent application failure.

None of these produce a log entry unless flow logging is enabled, and even then the entry records that a packet was rejected by an ACL without naming the rule, so the diagnosis still requires reading the ordered list and reasoning about which entry would have matched first. That reasoning is the same exercise as reading a route table: the decision procedure is short, and every surprise comes from applying a different rule than the one assumed.

References

  1. RFC 6335: Internet Assigned Numbers Authority (IANA) Procedures for the Management of the Service Name and Transport Protocol Port Number Registry — IETF
  2. Amazon Virtual Private Cloud User Guide — Network ACLs — Amazon Web Services