Analyzing ACLs and firewall rules with Batfish

Network and security engineers are responsible for ensuring that the ACLs and firewall rules in their networks are permitting and denying traffic as intended. This task usually requires manual checking or loading rulesets onto a lab device in order to test behavior on example packets of interest. These methods are not only time consuming but also error-prone.

Batfish makes it easy to deeply analyze ACLs and firewall rules, which we generally call filters. It can show what a filter will do with a given packet, right down to the line of the filter that matches the packet; it can provide guarantees on how a filter treats a large space of packets; and it can sanity check a filter to ensure that every line in it matches at least some packets.

In this notebook, we demonstrate these capabilities. In our “Provably Safe ACL and Firewall Changes” notebook, we show how Batfish can guarantee that filter changes are safe.

Check out a video demo of this notebook here.

Initialization

We use an example network with two devices, a router with ACLs and a firewall. The device configurations can be seen here.

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

# Initialize a network and a snapshot
bf.set_network("network-example-filters")

SNAPSHOT_NAME = "current"
SNAPSHOT_PATH = "networks/example-filters/current"
bf.init_snapshot(SNAPSHOT_PATH, name=SNAPSHOT_NAME, overwrite=True)
[1]:
'current'

testFilters: Testing how filters treat a flow

The testFilters question shows what filters do with a particular flow and why. It takes as input the details of the flow and a set of filters to test. The answer provides a detailed view of how the flow is treated by each filter in the set.

Flows are specified using source and destination IP addresses and, optionally, other fields such as IP protocols, ports, and TCP flags. You may also specify the ingress interface which is factored in when the behavior of a filter depends on it. See documentation for details. The question will fill in any unspecified fields with reasonable defaults.

The set of filters to examine can be narrowed using various criteria, including regexes for node and filter names. Left unspecified, testFilters will give results for every filter in the network.

Example 1: Test if hosts in a subnet can reach the DNS server

Suppose we wanted to test if our ACL acl_in in router rtr-with-acl allows hosts in a subnet to reach our DNS server. This check is easily expressed. If the subnet is 10.10.10.0/24 and the DNS server is at the IP address 218.8.104.58, then the query will be as shown below, where we have picked 10.10.10.1 as a representative source IP for the subnet.

[2]:
# Check if a representative host can reach the DNS server
dns_flow = HeaderConstraints(srcIps="10.10.10.1",
                             dstIps="218.8.104.58",
                             applications=["dns"])
answer = bf.q.testFilters(headers=dns_flow,
                         nodes="rtr-with-acl",
                         filters="acl_in").answer()
show(answer.frame())
Node Filter_Name Flow Action Line_Content Trace
0 rtr-with-acl acl_in Start Location: rtr-with-acl
Src IP: 10.10.10.1
Src Port: 49152
Dst IP: 218.8.104.58
Dst Port: 53
IP Protocol: UDP
PERMIT 660 permit udp 10.10.10.0/24 218.8.104.58/32 eq domain
  • Matched line 660 permit udp 10.10.10.0/24 218.8.104.58/32 eq domain

The result above shows that the flow we tested is indeed permitted by the filter acl_in (in Filter_Name column) on device rtr-with-acl (in Node column). The Flow column shows the exact flow tested, including the default values chosen by Batfish for unspecified parameters. The remaining columns show how the flow is treated by the filter. The Action column shows the final outcome, Line_Content shows which line(s) led to that action, and Trace shows all the lines the packet matched. In this case, the trace is not deep, but it can be quite deep for filters that reference other structures such as policy maps and object groups.

Example 2: Test that HTTP flows cannot go from one zone to another

Now suppose we want to test that our firewall blocks HTTP flows that cross the boundary from one zone to another. A query like the one below can help us do that. It is testing how output filters on interfaces in the second zone treat a packet with the specified header fields when it enters the firewall through interfaces in the first zone.

The arguments to startLocation and filters below use Batfish specifiers that enable easy specification of various concepts. enter(firewall[Ethernet0/0/2]) specifies that the packet we want to test enters ‘firewall’ via ‘Ethernet0/0/2’ (the interface in the first zone) and outFilterOf(Ethernet0/0/3) specifies that the filter we want to test is the out filter on ‘Ethernet0/0/3’ (the interface in the second zone).

[3]:
# Test if a host can reach the DNS server
http_flow = HeaderConstraints(srcIps="10.114.64.1",
                              dstIps="10.114.60.10",
                              applications=["http"])
answer = bf.q.testFilters(headers=http_flow,
                         startLocation="@enter(firewall[GigabitEthernet0/0/2])",
                         filters="@out(GigabitEthernet0/0/3)").answer()
show(answer.frame())
Node Filter_Name Flow Action Line_Content Trace
0 firewall ~ZONE_OUTGOING_ACL~zone-z3~ Start Location: firewall interface=GigabitEthernet0/0/2
Src IP: 10.114.64.1
Src Port: 49152
Dst IP: 10.114.60.10
Dst Port: 80
IP Protocol: TCP (SYN)
DENY no-match

    As we can see the HTTP flow is indeed denied, and the columns in the table show the reason for that denial.

    searchFilters: Verifying how filters treat a (very large) space of flows

    While testFilters reasons about individual flows, the searchFilters question provides comprehensive guarantees by reasoning about (potentially very large) spaces of flows. The space of flows is specified using source and destination prefixes (where the default prefix 0.0.0.0/0 denotes all 4,294,967,296 possibilities), a list of protocols, a range of ports, and so on. Given a flow space and a match condition, which can be ‘permit’, ‘deny’, or ‘matchLine ’, searchFilters returns flows within this space that match the condition. If no flow is returned, it is guaranteed that no flow in the entire space satisfies the condition.

    Example 1: Verify that all hosts in a subnet can reach the DNS server

    Earlier we checked that a subnet could reach the DNS server using testFilters with a representative source IP address. Now, let’s use searchfilter to ascertain that the entire subnet can reach the server.

    The query below is asking if there is any flow from 10.10.10.0/24 to 218.8.104.58 that is denied by the filter acl_in. That is, we’re asking for violations of the desired policy. An empty result means the policy is correctly implemented. Any flow returned by the query demonstrates that the policy is not correctly implemented.

    [4]:
    
    # Check if the subnet can reach the DNS server
    dns_traffic = HeaderConstraints(srcIps="10.10.10.0/24",
                                    dstIps="218.8.104.58",
                                    applications=["dns"])
    answer = bf.q.searchFilters(headers=dns_traffic,
                               action="deny",
                               filters="acl_in").answer()
    show(answer.frame())
    
    Node Filter_Name Flow Action Line_Content Trace
    0 rtr-with-acl acl_in Start Location: rtr-with-acl
    Src IP: 10.10.10.42
    Src Port: 49152
    Dst IP: 218.8.104.58
    Dst Port: 53
    IP Protocol: UDP
    DENY 460 deny udp 10.10.10.42/32 218.8.104.58/32 eq domain
    • Matched line 460 deny udp 10.10.10.42/32 218.8.104.58/32 eq domain

    As we can see, we did get a flow that matches the search condition and thus violates our desired guarantee of the entire subnet being able to reach the DNS server. The columns carry the same information as those for testFilters and provide insight into the violation. In particular, we see that a flow with source IP 10.10.10.42 is denied by an earlier line in the ACL. Such needles in the haystack are impossible to find with tools like pen testing.

    Example 2: Verify that the only TCP traffic that can go from one zone to another is NFS traffic

    In the second example for testFilters we used an example flow to test if firewall denied HTTP flows from one zone to another. Now, suppose that we wanted a stricter condition—that only TCP-based NFS flows are allowed from one zone to another. With searchFilters we can verify this condition by searching for violations in the large space of non-NFS flows, as below.

    [5]:
    
    # Check if any non-NFS, TCP flow can go from one zone to another
    non_nfs_tcp_traffic = HeaderConstraints(srcIps="101.164.101.231",
                                            dstIps="101.164.9.0/24",
                                            ipProtocols=["tcp"],
                                            dstPorts="!2049")  # exclude NFS port (2049)
    answer = bf.q.searchFilters(headers=non_nfs_tcp_traffic,
                               startLocation="@enter(firewall[GigabitEthernet0/0/2])",
                               filters="@out(GigabitEthernet0/0/3)",
                               action="permit").answer()
    show(answer.frame())
    
    Node Filter_Name Flow Action Line_Content Trace

    Since we got back an empty answer, we can be certain that no non-NFS flow can go from one zone to another.

    Such strong guarantees are impossible with any other tool today.

    filterLineReachability: Analyzing reachability of filter lines

    When debugging or editing filters, it can be useful to confirm that every line is reachable—that is, each line can match some packets that do not match earlier lines. Unreachable filter lines are often symptomatic of bugs and past edits that did not achieve policy intent. Even when they do not represent bugs, they represent opportunities to reduce the length of the filter.

    The filterLineReachability question identifies unreachable filter lines. Given no parameters, it will check every filter in the network, but its scope can be narrowed using filters and nodes parameters (see documentation).

    [6]:
    
    # Find unreachable lines in filters of rtr-with-acl
    aclAns = bf.q.filterLineReachability(nodes="rtr-with-acl").answer()
    show(aclAns.frame().sort_values(by="Unreachable_Line"))
    
    Sources Unreachable_Line Unreachable_Line_Action Blocking_Lines Different_Action Reason Additional_Info
    1 rtr-with-acl: acl_in 670 permit ip 166.146.58.184 any PERMIT 540 deny ip 166.144.0.0/12 any True BLOCKING_LINES None
    0 rtr-with-acl: acl_in 790 deny ip 54.203.159.1/32 any DENY 500 deny ip 54.0.0.0/8 any False BLOCKING_LINES None

    Each line in the answer above identifies an unreachable line in a filter. Let’s take a closer look at the first one. It shows that the line 670 permit ip 166.146.58.184 any is unreachable because it is blocked by the line 540 shown in the Blocking_Lines column. The Different_Action column indicates that the blocking line 540 has the opposite action as the blocked line 670, a more worrisome situation than if actions were the same.

    The Reason column shows that the line is unreachable because of it has other lines that block it, Lines can also be independently unreachable (i.e., no packet will ever match it) or may be unreachable because of circular references. The filterLineReachability question identifies such cases as well, and provides more information about them in the Additional_Info column.

    Summary

    In this notebook, we showed how you can use Batfish to: 1. Test how filters in the network treat a flow (testFilters) 2. Verify how filters treat a large space of flows (searchFilters) 3. Find filter lines that will never match any packet (filterLineReachability)

    If you found these capabilities useful, check out our “Provably Safe ACL and Firewall Changes” notebook that shows how to change filters in a provably safe manner.


    Get involved with the Batfish community

    Join our community on Slack and GitHub.