Safely refactoring ACLs and firewall rules

Changing ACLs or firewall rules (or filters) is one of the riskiest updates to a network. Even a small error can block connectivity for a large set of critical services or open up sensitive resources to the world at large. Earlier notebooks showed how to analyze filters for what they do and do not allow and how to make specific changes in a provably safe manner.

This notebook shows how to refactor complex filters in a way that the full impact of refactoring can be understood and analyzed for correctness before refactored filters are pushed to the network.

Original ACL

We will use the following ACL as a running example in this notebook. The ACL can be read as a few separate sections:

  • Line 10: Deny ICMP redirects

  • Lines 20, 23: Permit BFD traffic on certain blocks

  • Lines 40-80: Permit BGP traffic

  • Lines 90-100: Permit DNS traffic a /24 subnet while denying it from a /32 within that

  • Lines 110-500: Permit or deny IP traffic from certain subnets

  • Line 510: Permit ICMP echo reply

  • Lines 520-840: Deny IP traffic to certain subnets

  • Lines 850-880: Deny all other types of traffic

(The IP address space in the ACL appears all over the place because it has been anonymized via Netconan. Netconan preserves the super- and sub-prefix relationships when anonymizing IP addresses and prefixes.)

[1]:
# The ACL before refactoring
original_acl = """
ip access-list acl
  10 deny icmp any any redirect
  20 permit udp 117.186.185.0/24 range 49152 65535 117.186.185.0/24 eq 3784
  30 permit udp 117.186.185.0/24 range 49152 65535 117.186.185.0/24 eq 3785
  40 permit tcp 11.36.216.170/32 11.36.216.169/32 eq bgp
  50 permit tcp 11.36.216.176/32 11.36.216.179/32 eq bgp
  60 permit tcp 204.150.33.175/32 204.150.33.83/32 eq bgp
  70 permit tcp 205.248.59.64/32 205.248.59.67/32 eq bgp
  80 permit tcp 205.248.58.190/32 205.248.58.188/32 eq bgp
  90 deny udp 10.10.10.42/32 218.8.104.58/32 eq domain
  100 permit udp 10.10.10.0/24 218.8.104.58/32 eq domain
  110 deny ip 54.0.0.0/8 any
  120 deny ip 163.157.0.0/16 any
  130 deny ip 166.144.0.0/12 any
  140 deny ip 198.170.50.0/24 any
  150 deny ip 198.120.0.0/16 any
  160 deny ip 11.36.192.0/19 any
  170 deny ip 11.125.64.0/19 any
  180 permit ip 166.146.58.184/32 any
  190 deny ip 218.66.57.0/24 any
  200 deny ip 218.66.56.0/24 any
  210 deny ip 218.67.71.0/24 any
  220 deny ip 218.67.72.0/24 any
  230 deny ip 218.67.96.0/22 any
  240 deny ip 8.89.120.0/22 any
  250 deny ip 54.203.159.1/32 any
  260 permit ip 218.8.104.0/25 any
  270 permit ip 218.8.104.128/25 any
  280 permit ip 218.8.103.0/24 any
  290 deny ip 144.49.45.40/32 any
  300 deny ip 163.255.18.63/32 any
  310 deny ip 202.45.130.141/32 any
  320 deny ip 212.26.132.18/32 any
  330 deny ip 218.111.16.132/32 any
  340 deny ip 218.246.165.90/32 any
  350 deny ip 29.228.179.210/32 any
  360 deny ip 194.181.135.214/32 any
  370 deny ip 10.64.90.249/32 any
  380 deny ip 207.70.46.217/32 any
  390 deny ip 219.185.241.117/32 any
  400 deny ip 2.80.3.219/32 any
  410 deny ip 27.212.145.150/32 any
  420 deny ip 131.159.53.215/32 any
  430 deny ip 214.220.213.107/32 any
  440 deny ip 196.64.84.239/32 any
  450 deny ip 28.69.250.136/32 any
  460 deny ip 200.45.87.238/32 any
  470 deny ip any 11.125.89.32/30
  480 deny ip any 11.125.89.36/30
  490 deny ip any 11.125.89.40/30
  500 deny ip any 11.125.89.44/30
  510 permit icmp any any echo-reply
  520 deny ip any 11.36.199.216/30
  530 deny ip any 11.36.199.36/30
  540 deny ip any 11.36.199.2/30
  550 deny ip any 11.36.199.52/30
  560 deny ip any 11.36.199.20/30
  570 deny ip any 11.125.82.216/30
  580 deny ip any 11.125.82.220/32
  590 deny ip any 11.125.82.36/30
  600 deny ip any 11.125.82.12/30
  610 deny ip any 11.125.80.136/30
  620 deny ip any 11.125.80.141/32
  630 deny ip any 11.125.87.48/30
  640 deny ip any 11.125.87.168/30
  650 deny ip any 11.125.87.173/32
  660 deny ip any 11.125.90.56/30
  670 deny ip any 11.125.90.240/30
  680 deny ip any 11.125.74.224/30
  690 deny ip any 11.125.91.132/30
  700 deny ip any 11.125.89.132/30
  710 deny ip any 11.125.89.12/30
  720 deny ip any 11.125.92.108/30
  730 deny ip any 11.125.92.104/32
  740 deny ip any 11.125.92.28/30
  750 deny ip any 11.125.92.27/32
  760 deny ip any 11.125.92.160/30
  770 deny ip any 11.125.92.164/32
  780 deny ip any 11.125.92.204/30
  790 deny ip any 11.125.92.202/32
  800 deny ip any 11.125.93.192/29
  810 deny ip any 11.125.95.204/30
  820 deny ip any 11.125.95.224/30
  830 deny ip any 11.125.95.180/30
  840 deny ip any 11.125.95.156/30
  850 deny tcp any any
  860 deny icmp any any
  870 deny udp any any
  880 deny ip any any
"""

Compressed ACL

Now, assume that we want to compress this ACL to make it more manageable. We do the following operations:

  • Merge the two BFD permit statements on lines 20-30 into one statement using the range directive.

  • Remove the BGP session on line 80 because it has been decommissioned

  • Remove lines 180 and 250 because they are shadowed by earlier lines and will never match a packet. Such lines can be found via the filterLineReachability question, as shown here.

  • Merge pairs of lines (190, 200), (210, 220), and (260, 270) by combining their prefixes into a less specific prefix.

  • Remove all deny statements on lines 520-870. They are not needed given the final deny on line 880.

The result of these actions, which halve the ACL size, is shown below. To enable easy observation of changes, we have preserved the line numbers.

[2]:
compressed_acl = """
ip access-list acl
  10 deny icmp any any redirect
  20 permit udp 117.186.185.0/24 range 49152 65535 117.186.185.0/24 range 3784 3785
! 30 MERGED WITH LINE ABOVE
  40 permit tcp 11.36.216.170/32 11.36.216.169/32 eq bgp
  50 permit tcp 11.36.216.176/32 11.36.216.179/32 eq bgp
  60 permit tcp 204.150.33.175/32 204.150.33.83/32 eq bgp
  70 permit tcp 205.248.59.64/32 205.248.59.67/32 eq bgp
! 80 DECOMMISSIONED BGP SESSION
  90 deny udp 10.10.10.42/32 218.8.104.58/32 eq domain
  100 permit udp 10.10.10.0/24 218.8.104.58/32 eq domain
  110 deny ip 54.0.0.0/8 any
  120 deny ip 163.157.0.0/16 any
  130 deny ip 166.144.0.0/12 any
  140 deny ip 198.170.50.0/24 any
  150 deny ip 198.120.0.0/16 any
  160 deny ip 11.36.192.0/19 any
  170 deny ip 11.125.64.0/19 any
! 180 REMOVED UNREACHABLE LINE
  190 deny ip 218.66.56.0/23 any
! 200 MERGED WITH LINE ABOVE
  210 deny ip 218.67.71.0/23 any
! 220 MERGED WITH LINE ABOVE
  230 deny ip 218.67.96.0/22 any
  240 deny ip 8.89.120.0/22 any
! 250 REMOVED UNREACHABLE LINE
  260 permit ip 218.8.104.0/24 any
! 270 MERGED WITH LINE ABOVE
  280 permit ip 218.8.103.0/24 any
  290 deny ip 144.49.45.40/32 any
  300 deny ip 163.255.18.63/32 any
  310 deny ip 202.45.130.141/32 any
  320 deny ip 212.26.132.18/32 any
  330 deny ip 218.111.16.132/32 any
  340 deny ip 218.246.165.90/32 any
  350 deny ip 29.228.179.210/32 any
  360 deny ip 194.181.135.214/32 any
  370 deny ip 10.64.90.249/32 any
  380 deny ip 207.70.46.217/32 any
  390 deny ip 219.185.241.117/32 any
  400 deny ip 2.80.3.219/32 any
  410 deny ip 27.212.145.150/32 any
  420 deny ip 131.159.53.215/32 any
  430 deny ip 214.220.213.107/32 any
  440 deny ip 196.64.84.239/32 any
  450 deny ip 28.69.250.136/32 any
  460 deny ip 200.45.87.238/32 any
  470 deny ip any 11.125.89.32/28
  510 permit icmp any any echo-reply
! 520-870 REMOVED UNNECESSARY DENIES
  880 deny ip any any
"""

The challenge for us is to find out if and how this compressed ACL differs from the original. That is, is there is traffic that is treated differently by the two ACLs, and if so, which lines are responsible for the difference.

This task is difficult to get right through manual reasoning alone, which is why we developed the compareFilters question in Batfish.

Comparing filters

We can compare the two ACLs above as follows. To initialize snapshots, we will use Batfish’s init_snapshot_from_text function which creates a snapshot with a single device who configuration is the provided text. The analysis shown below can be done even when the filters are embedded within bigger device configurations.

[3]:
# Import packages
%run startup.py
bf = Session(host="localhost")

# Initialize a snapshot with the original ACL
original_snapshot = bf.init_snapshot_from_text(
    original_acl,
    platform="cisco-nx",
    snapshot_name="original",
    overwrite=True)

# Initialize a snapshot with the compressed ACL
compressed_snapshot = bf.init_snapshot_from_text(
    compressed_acl,
    platform="cisco-nx",
    snapshot_name="compressed",
    overwrite=True)

# Now, compare the two ACLs in the two snapshots
answer = bf.q.compareFilters().answer(snapshot=compressed_snapshot, reference_snapshot=original_snapshot)
show(answer.frame())
Node Filter_Name Line_Index Line_Content Line_Action Reference_Line_Index Reference_Line_Content
0 config acl 16 210 deny ip 218.67.71.0/23 any DENY 50 510 permit icmp any any echo-reply
1 config acl 40 510 permit icmp any any echo-reply PERMIT 21 220 deny ip 218.67.72.0/24 any
2 config acl 41 880 deny ip any any DENY 7 80 permit tcp 205.248.58.190/32 205.248.58.188/32 eq bgp

The compareFilters question compares two filters and returns pairs of lines, one from each filter, that match the same flow(s) but treat them differently. If it reports no output, the filters are guaranteed to be identical. The analysis is exhaustive and considers all possible flows.

As we can see from the output above, our compressed ACL is not the same as the original one. In particular, line 210 of the compressed ACL will deny some flows that were being permitted by line 510 of the original; and line 510 of the compressed ACL will permit some flows that were being denied by line 220 of the original ACL. Because the permit statements correspond to ICMP traffic, we can tell that the traffic treated by the two filters is ICMP. To narrow learn specific source and destination IPs that are impacted, one may run the searchFilters question, as shown here.

By looking at the output above, we can immediately understand the difference:

  • The first line is showing that the compressed ACL is denying some traffic on line 210 (with index 16) that the original ACL was permitting via line 510, and the compressed ACL is permitting some traffic on line 510 that the original ACL was denying via line 220.

    It turns out that the address space merger we did for lines 210 and 220 in the original ACL, where we combined 218.67.72.0/24 and 218.67.71.0/24 into 218.67.71.0/23, was not correct. The other similar mergers of 218.66.57.0/24 and 218.66.56.0/24 into 218.66.56.0/23 and of 218.8.104.0/25 and 218.8.104.128/25 into 218.8.104.0/24 were correct.

  • The third line is showing that the compressed ACL is denying some traffic at the end of the ACL that the original ACL was permitting via line 80. This is an expected change of decommissioning the BGP session on line 80.

    It is not always the case that refactoring is semantics preserving. Where compareFilters helps is succinctly enumerating all differences. Engineers can look at the differences and decide if the refactored filter meets their intent.

Splitting ACLs

Compressing large ACLs is one type of refactoring engineers do; another one is splitting a large ACL into multiple smaller ACLs and composing them on the same device or spreading across multiple devices in the network. Smaller ACLs are easier to maintain and evolve. However, the split operation is risky. We may forget to include in the smaller ACLs some protections that exist in the original ACL. We show how such splits can be safely done using Batfish.

Suppose we want to split the compressed ACL above into multiple smaller ACLs that handle different concerns. So, we should have different ACLs for different types of traffic and different ACLs for different logical groups of nodes in the network. The result of such splitting is shown below. For ease of exposition, we have retained the line numbers from the original ACL and mimic a scenario in which all ACLs live on the same device.

[4]:
smaller_acls = """
ip access-list deny-icmp-redirect
  10 deny icmp any any redirect

ip access-list permit-bfd
  20 permit udp 117.186.185.0/24 range 49152 65535 117.186.185.0/24 range 3784 3785

ip access-list permit-bgp-session
  40 permit tcp 11.36.216.170/32 11.36.216.169/32 eq bgp
  50 permit tcp 11.36.216.176/32 11.36.216.179/32 eq bgp
  60 permit tcp 204.150.33.175/32 204.150.33.83/32 eq bgp
  70 permit tcp 205.248.59.64/32 205.248.59.67/32 eq bgp

ip access-list acl-dns
  90 deny udp 10.10.10.42/32 218.8.104.58/32 eq domain
  100 permit udp 10.10.10.0/24 218.8.104.58/32 eq domain

ip access-list deny-untrusted-sources-group1
  110 deny ip 54.0.0.0/8 any
  120 deny ip 163.157.0.0/16 any
  130 deny ip 166.144.0.0/12 any
  140 deny ip 198.170.50.0/24 any
  150 deny ip 198.120.0.0/16 any
  160 deny ip 11.36.192.0/19 any

ip access-list deny-untrusted-sources-group2
  160 deny ip 11.36.192.0/20 any
  190 deny ip 218.66.56.0/23 any
  210 deny ip 218.67.71.0/23 any
  230 deny ip 218.67.96.0/22 any
  240 deny ip 8.89.120.0/22 any

ip access-list permit-trusted-sources
  260 permit ip 218.8.104.0/24 any
  280 permit ip 218.8.103.0/24 any

ip access-list deny-untrusted-sources-group3
  290 deny ip 144.49.45.40/32 any
  300 deny ip 163.255.18.63/32 any
  310 deny ip 202.45.130.141/32 any
  320 deny ip 212.26.132.18/32 any
  300 deny ip 218.111.16.132/32 any
  340 deny ip 218.246.165.90/32 any
  350 deny ip 29.228.179.210/32 any
  360 deny ip 194.181.135.214/32 any
  370 deny ip 10.64.90.249/32 any
  380 deny ip 207.70.46.217/32 any
  390 deny ip 219.185.241.117/32 any

ip access-list deny-untrusted-sources-group4
  400 deny ip 2.80.3.219/32 any
  410 deny ip 27.212.145.150/32 any
  420 deny ip 131.159.53.215/32 any
  430 deny ip 214.220.213.107/32 any
  440 deny ip 196.64.84.239/32 any
  450 deny ip 28.69.250.136/32 any
  460 deny ip 200.45.87.238/32 any

ip access-list acl-tail
  470 deny ip any 11.125.89.32/28
  510 permit icmp any any echo-reply
  880 deny ip any any
"""

Given the split ACLs above, one analysis may be to figure out if each untrusted source subnet was included in a smaller ACL. Otherwise, we have lost protection that was present in the original ACL. We can accomplish this analysis via the findMatchingFilterLines question, as shown below.

Once we are satisfied with analysis of filters, for an end-to-end safety guarantee, we should also analyze if there are new flows that the network will allow (or disallow) after the change. Such an analysis can be done via the differentialReachability question, as shown here.

[5]:
# Initialize a snapshot with the smaller ACLs
smaller_snapshot = bf.init_snapshot_from_text(
    smaller_acls,
    platform="cisco-nx",
    snapshot_name="smaller",
    overwrite=True)

# All untrusted subnets
untrusted_source_subnets = ["54.0.0.0/8",
                            "163.157.0.0/16",
                            "166.144.0.0/12",
                            "198.170.50.0/24",
                            "198.120.0.0/16",
                            "11.36.192.0/19",
                            "11.125.64.0/19",
                            "218.66.56.0/24",
                            "218.66.57.0/24",
                            "218.67.71.0/23",
                            "218.67.96.0/22",
                            "8.89.120.0/22"
                           ]

for subnet in untrusted_source_subnets:
    # Find which ACLs match traffic from this source subnet
    answer = bf.q.findMatchingFilterLines(
        headers=HeaderConstraints(srcIps=subnet),
        filters="/deny-untrusted/").answer(snapshot=smaller_snapshot)

    # Each source subnet should match exactly one ACL
    af = answer.frame()
    if len(af) == 1:
        print("{} .... OK".format(subnet))
    elif len(af) == 0:
        print("{} .... ABSENT".format(subnet))
    else:
        print("{} .... Multiply present".format(subnet))
        show(af)
54.0.0.0/8 .... OK
163.157.0.0/16 .... OK
166.144.0.0/12 .... OK
198.170.50.0/24 .... OK
198.120.0.0/16 .... OK
11.36.192.0/19 .... Multiply present
Node Filter Line Line_Index Action
0 config deny-untrusted-sources-group1 160 deny ip 11.36.192.0/19 any 5 DENY
1 config deny-untrusted-sources-group2 160 deny ip 11.36.192.0/20 any 0 DENY
11.125.64.0/19 .... ABSENT
218.66.56.0/24 .... OK
218.66.57.0/24 .... OK
218.67.71.0/23 .... OK
218.67.96.0/22 .... OK
8.89.120.0/22 .... OK

In the code above, we first enumerate all untrusted subnets in the network. The granularity of this specification need not be the same as that in the ACL. For instance, we enumerate 218.66.56.0/24 and 218.66.57.0/24 as untrusted subnets but the ACL has a less specific prefix 218.66.56.0/23. Batfish understands such relationships and provides an accurate analysis that is not possible with simple string matching.

The for loop above uses the findMatchingFilterLines question to find out which lines across all ACLs whose names contain “deny-untrusted” will match packets starting the the specified subnet. Our expectation is that each subnet should match exactly one line in exactly one ACL, and the output shows “OK” against such subnets. It shows “Absent” for subnets that do not match any line and shows the multiple matching lines for subnets where that happens.

We see that during the split above, we ended up matching the subnet 11.36.192.0/19 twice, once as a /19 in ACL deny-untrusted-sources-group1 and then as /20 in ACL deny-untrusted-sources-group2. More dangerously, we completely forgot to match the 11.125.64.0/19, which will open a security hole in the network if these smaller ACLs were applied.

Summary

In this notebook, we showed how to use the compareFilters and findMatchingFilterLines questions of Batfish to safely refactor complex filters.

  • compareFilters analyzes the original and revised filter to enumerate all cases that will treat any flow differently.

  • findMatchingFilterLines enumerates all lines across all specified filters that match the given space of flows.

For additional ways to analyze filter using Batfish, see the “Analyzing ACLs and Firewall Rules” and the “Provably Safe ACL and Firewall Changes” notebooks.


Get involved with the Batfish community

Join our community on Slack and GitHub.