NetKubeLab ไทย

Firewalls — the last matching rule wins

A firewall does not decide what is safe, it decides what matches a rule — and the packet filter shipping with macOS defaults to pass, not block

Most people picture a firewall as a wall: an inside, an outside, and a guard at the gate.

That picture misses the thing that causes most misconfigurations — a firewall is an ordered list of rules, and the outcome depends on three things people rarely read: which rule wins when several match, what happens when none match, and whether the filter remembers connections at all.

macOS ships with a packet filter called pf, with a full manual page, and the answers to all three are in it. The second one surprises people.

If you have never looked, start here

The system's rule file is in one place, readable without any privileges.

$ cat /etc/pf.conf

On the machine this was written on, that file contains no filter rules at all — only anchor points where other components hang their own.

  scrub-anchor "com.apple/*"
  nat-anchor "com.apple/*"
  rdr-anchor "com.apple/*"
  dummynet-anchor "com.apple/*"
  anchor "com.apple/*"

And its manual page is the best source for everything in this article.

$ man pf.conf

The last matching rule wins

This is the sentence to read before writing your first rule, from the manual page on this machine.

"Filter rules are evaluated in sequential order, from first to last. The last matching rule decides what action is taken."

Two rule sets with identical text in different order producing opposite results, because the last matching rule is the one that decides

The last, not the first — the reverse of what most people expect, and pf knows it, which is why it offers a quick keyword to switch to first-match behaviour, described below.

The result is that two rule sets with identical text, in different order, do opposite things.

  set A
    block in all
    pass  in proto tcp to port 22
                                    -> ssh passes, rest blocked

  set B
    pass  in proto tcp to port 22
    block in all
                                    -> everything blocked, ssh too

In set B, block in all also matches the ssh packet and is the last rule to do so, so it wins and closes what the line above had just opened.

The default is pass, not block

The next sentence in the same manual page is the one I find most alarming.

"If no rule matches the packet, the default action is to pass the packet."

A packet matching no rule at all is passed by default, and getting default-deny requires writing a block all rule as the first line yourself

A filter with no rules passes everything, and a filter whose rules do not cover a case passes everything the rules did not mention.

The manual page states the fix in the same paragraph.

"The simplest mechanism to block everything by default and only pass packets that match explicit rules is specify a first filter rule of: block all"

Combine the two facts and the correct shape appears — always start with block all, then open things one at a time on the lines below, because later lines beat earlier ones.

The dangerous case is a rule file that is already correct, to which someone appends a new line. That new line beats everything above it, possibly without the person realising.

quick — when you want first match to win

If you want first-match behaviour, there is a keyword for it.

"If a packet matches a rule which has the quick option set, this rule is considered the last matching rule, and evaluation of subsequent rules is skipped."

  block in quick from 203.0.113.0/24
  pass  in all

That range is blocked immediately without reading the line below; everything else passes.

quick makes long rule files far easier to read, but it introduces the opposite hazard — a quick line higher up shadows the lines below that someone expected to take effect.

Stateful and stateless

Older filters look at each packet on its own and remember nothing; that is stateless. Modern filters remember which connections were opened; that is stateful.

A stateless filter examines packets individually so a return-traffic rule has to be written by hand, while a stateful filter remembers outbound connections and lets their return traffic through automatically

pf is stateful by default. The manual page says:

"pass — The packet is passed; state is created unless the no state option is specified"

The difference matters because every connection has two directions. Open only the outbound direction while remembering nothing, and the return traffic is blocked, so nothing works at all.

Stateless means writing a return-traffic rule every time, and that rule is itself the hole, as the next section shows.

Stateful means opening the outbound direction is enough; the return traffic for that connection is allowed automatically, while any packet that matches no state at all is blocked.

The trap in stateless rules

The way people used to open return traffic on a stateless filter was to look at the ACK bit in the TCP header, reasoning that return packets have it set while a new connection carries only SYN.

A genuine reply and an attacker-made packet carry an identical ACK bit, so a rule that inspects only that bit cannot tell them apart

The problem is that those flags are just bits in one byte, written by the sender.

  FIN   0x01   00000001
  SYN   0x02   00000010
  RST   0x04   00000100
  PSH   0x08   00001000
  ACK   0x10   00010000
  URG   0x20   00100000

I built two TCP segments, one a genuine reply and one made by an attacker who simply set the ACK bit.

  real reply              flags=0x10   passes the rule
  attacker-made packet    flags=0x10   passes the rule
  new connection (SYN)    flags=0x02   blocked

The first two are identical byte for byte. A rule inspecting only flags cannot distinguish them in principle, not for lack of skill in writing rules.

A stateful filter can, because it does not inspect the bit. It asks whether a connection matching these four values exists in its table — source address, destination address, source port, destination port. A packet matching no entry is discarded.

The state table can fill up

That memory is finite, and the manual page documents it as a per-rule option.

"max ⟨number⟩ — Limits the number of concurrent states the rule may create. When this limit is reached, further packets that would create state will not match this rule until existing states time out."

That sentence describes a common failure shape — when the table is full, new connections are refused while the CPU is idle and the circuit is not full.

The arithmetic is direct. If an attacker opens a thousand connections per second and leaves them hanging:

  timeout  30 s   ->    30,000 entries held at once
  timeout  60 s   ->    60,000 entries held at once
  timeout 300 s   ->   300,000 entries held at once

A longer timeout requires a proportionally larger table. This is why shortening timeouts is an effective defence, and why the manual page lets you set them per rule.

What a firewall cannot see

A firewall sees addresses, ports and volume, but once the connection is encrypted it cannot see the content inside

A filter at layers 3 and 4 sees these:

  source address        visible
  destination address   visible
  port                  visible
  flags                 visible
  payload               bytes visible, unreadable if encrypted

The TLS article explains that content is encrypted end to end, which means a firewall sees who is talking to whom, not what they are saying. What remains visible is the destination name in SNI, as that article points out, plus the volume and timing of the data.

And plenty of things are outside its scope entirely — people inside who already have access, files carried in another way, and flaws in the services you deliberately opened.

When it lies

"We have a firewall, so we are safe." A firewall enforces what you wrote. It does not judge what should be allowed. If a rule opens a port that should not be open, it opens it faithfully.

"No rules means nothing gets through." For pf the opposite is true — the manual page states that when no rule matches, the default is to pass.

"Append a pass rule to open something extra." That can do the reverse, because the appended line becomes the last match and cancels a block rule above it.

"A return-traffic rule using ACK is the same thing." It is not, per the experiment above. Flags are bits the sender writes. Only a stateful filter can tell the difference.

"Blocking ping makes things safer." The ping article shows that blocking ICMP breaks path MTU discovery and makes troubleshooting far harder, in exchange for almost no security.

"A firewall sees everything that passes through it." It sees headers, not encrypted content — and nothing at all that does not pass through it.

Real cases from real work

Case 1 — adding a rule breaks a different service

Situation A line was appended to open a new port, and a service that used to work stopped.

How to read it If the new line is broader than intended — an all slipped in — it becomes the last matching rule for the other service's packets too, and beats the rules above. The way to check is to read the file from the bottom up, looking for the last line that matches the broken traffic, not from the top down.

What this does not prove Rule order explains this symptom, but a quick line higher up has not been ruled out, and the live rule set may contain anchors into which other components insert rules at runtime — rules that do not appear in the file you opened.

Case 2 — new connections refused while the machine is idle

Situation CPU under ten percent, uplink not saturated, but new users cannot connect.

How to read it Check the state table first. The manual page already explains that once the limit is reached, packets that would create new state stop matching the rule until existing entries time out. The symptom is therefore "existing connections fine, new ones refused", which is a different signature from an overloaded machine.

What this does not prove A full table says a lot is being held; it does not say whether that is an attack or a timeout set too long for normal traffic. You have to compare the state creation rate against the timeout.

Case 3 — writing a first rule set correctly

Situation Setting up rules on a new machine.

How to read it This order is safe because it respects both rules quoted above.

  1  block all                      close everything, as the first line
  2  pass out proto tcp keep state  open outbound and remember it
  3  pass in proto tcp to port 22   open only the service you intend

Line 1 turns the default into block. Later, more specific lines each win in turn. And because state is remembered, no return-traffic rule is needed at all.

What this does not prove This is a skeleton, not a usable file. It says nothing about loopback, about the ICMP needed for path MTU discovery, or about DHCP, which has to pass before the machine even has an address. All three are why a bare block all usually leaves a machine unusable.

When a firewall is not enough

A firewall answers who may talk to whom, which is a good question but not the only one. It cannot say whether the party talking is who they claim to be — that needs authentication — nor whether the content was altered in transit, which needs the kind of encryption TLS describes.

And a firewall at the network edge sees nothing that moves inside without passing through it, which is why filtering on the machine itself still matters.

References

From the machine this was written on

  • man pf.conf — every sentence quoted here comes from that manual page on this machine: rule order, the default action, quick, state creation, and the max option
  • /etc/pf.conf — the system rule file, which on this machine holds only anchor points and no filter rules
  • man pfctl — the command that enables, disables and loads rules

Computed here

  • The TCP flag table, and the experiment building two segments carrying an identical ACK bit
  • The state table figures, computed as connection rate multiplied by timeout

อ่านหน้านี้เป็นภาษาไทย

← Back to the basics