SenseOn

14/11/2022

Adventures in Kernel Land: Hunting a Bug in Windows Filtering Platform

This post was written by SenseOn’s CTO, James Mistry.
Know all about cyber-flavoured alphabet soup? Go straight to the gory details!

Table of contents
Background

Network detection and response (NDR) is a category of security product which aims to identify malicious activity by analysing the communication between computers on a network. It is often regarded as complementary but distinct to endpoint detection and response (EDR) which focuses on analysing the changing internal state of a computer to find malicious activity. While an NDR might detect a piece of malware by observing a DNS request on the network for a name known to be used by the malware campaign, an EDR might detect the same malware instead by observing a program attempting to make destructive changes to a user’s data.

Traditionally, NDR is performed using dedicated physical or virtual hardware which receives a copy of traffic observed by a network component, typically a switch. This dedicated hardware inspects the copied traffic for evidence of malicious activity, sometimes also using the information to produce a historical record of communication between devices for retrospective manual or automated analysis.

There are some scenarios that justify dedicated NDR hardware, for example environments with a substantial proportion of IoT devices whose activity can’t be monitored by any other means. There are big downsides to using this approach everywhere, however.

Firstly, copying all traffic into NDR hardware requires an organisation to funnel communication from its devices into network “pinch points” at which this copying can happen. This is complex and expensive as the network has to grow with the increase in users’ Internet traffic. This is the case even when users are distributed across more than one corporate office, but particularly true when staff frequently work from many different locations (such as their homes) which have their own independent connections to the Internet. It also introduces a point of failure and slows network traffic down. As well as latency increases proportional to distance of around 1 millisecond per 100km in the best case, if using a VPN to funnel traffic through a corporate network the work that the VPN servers need to do (such as encryption and decryption) takes extra time. These VPN servers also need to be paid for and maintained, as does a large enough corporate Internet link to serve all users whose traffic is being funnelled through the network.

There are other costs and compromises, too:

Network Detection & Response on Endpoint

Since 2019, SenseOn’s endpoint software has served as both an endpoint detection and response (EDR) and “distributed” network detection and response (NDR) platform. This allows customers to avoid the cost and complexity of server-based NDR, while providing improved visibility and threat detection advantages across their estate.

The way SenseOn’s endpoint NDR component works varies a bit by platform. While the deep packet inspection (DPI) component is identical across Windows, macOS and Linux, the “packet acquisition” component is platform-specific. This component is responsible for low-level interaction with the operating system to “acquire” network packets being sent and received by applications running on the device. It then passes these packets to the DPI component for inspection and telemetry* generation.

* Telemetry is our term for structured records that describe potentially interesting activity and often serve as the input to automated or manual threat detection.

Windows Filtering Platform

On Windows, SenseOn’s endpoint software uses a kernel-mode driver to access data not available in user space. For packet acquisition, this means using a collection of Windows kernel features called Windows Filtering Platform. WFP provides trusted applications with access to the internal state of the Windows network stack and is intended as a replacement for older technologies such as NDIS (Network Driver Interface Specification).

WFP works through the use of “callouts”, code that applications register with WFP to be called when particular network stack events occur. These events are called “layers” in WFP terminology, and broadly speaking they correspond to different points in the network send and receive paths. Many layers also provide the ability for callouts to block an action the layer relates to, such as initiating a TCP connection.

Below is an example of a callout to be registered with the FWPM_LAYER_ALE_AUTH_CONNECT_V4 filtering layer. This layer sits on the send path (it concerns traffic being sent) before the operating system has made a TCP connection or sent the first TCP packet in a connection.

_IRQL_requires_same_
_IRQL_requires_max_(DISPATCH_LEVEL)
void NTAPI handle_tcp_flow_classify(
    _In_ const FWPS_INCOMING_VALUES  * inFixedValues,
    _In_ const FWPS_INCOMING_METADATA_VALUES  * inMetaValues,
    _Inout_ VOID  * layerData, 
    _In_ const VOID * classifyContext, 
    _In_ const FWPS_FILTER  * filter,
    _In_ UINT64  flowContext, 
    _Inout_ FWPS_CLASSIFY_OUT  * classifyOut)
{
    /*
     * Code to process outgoing TCP connections.
     */
}

By inspecting the arguments passed to the function by WFP, information about the related TCP connection can be determined. For example, the inFixedValues argument struct members contain the local and remote IP addresses and ports in the connection tuple. The behaviour of the network stack can also be altered based on the processing done in the callout, for example by manipulating the classifyOut output argument to block or allow the connection.

It’s typical for layers to refer to a particular traffic direction (send or receive), although the same callout function might be registered by an application for both directions. This is possible because the callout arguments can be used to determine the direction of traffic being referred to in a specific invocation of the callout. The code below demonstrates how to determine the direction (the specific layer at which the callout is being made) by inspecting inFixedValues:

BOOLEAN inbound_traffic = 
((inFixedValues->incomingValue[FWPS_FIELD_STREAM_PACKET_V4_DIRECTION]
.value.uint8 == FWP_DIRECTION_INBOUND) ? TRUE : FALSE);


The Case of the Vanishing Traffic

Missing packets

Early in the development of our Windows packet acquisition component, we noticed some perplexing behaviour.

In one of our callouts, it appeared that we weren’t seeing outbound traffic despite us being able to prove that it was sent by the network interface and received by the remote application. In fact, we could even prove that the received traffic was passing through the network stack by observing it using the NDIS-based Npcap (used by Wireshark on Windows), as well as by collecting WFP traces.

In WFP terms:

  1. When an outbound TCP connection was attempted, the FWPM_LAYER_STREAM_PACKET_V4/6 callout was called twice: once for the SYN packet (outbound) and once for the ACK packet (inbound), as expected. This layer provides visibility of TCP packets, including those involved in handshakes.
  2. After this, once the TCP connection was established, the FWPM_LAYER_ALE_FLOW_ESTABLISHED_V4/6 callout was called, as expected. At this layer, our callout attached state to the TCP connection shared by subsequent callouts processing the connection. This layer indicates the successful establishment of a flow (TCP connection or datagram session).
  3. All subsequent packets received by the FWPM_LAYER_STREAM_PACKET_V4/6 callout (for processing TCP payloads) were inbound only, despite the fact the TCP connection was correctly established and could be proven to carry data in both directions between client and server. No outbound packets were ever passed to the callout from this point forward.

We spent a lot of time trying to figure out what was going on. To begin with, we assumed there was a bug in how we were registering for WFP callouts or interpreting WFP data structures in the callout code.

The particularly curious thing was that outbound handshake packets were correctly passed to, and interpreted by, our callout. It was only the post-handshake packets which were missing. This led us to suspect some kind of conflict with another application using WFP. Could another callout be manipulating the traffic or WFP behaviour in a way that caused it to bypass ours?

Our Windows development environments were hosted on AWS VMs, and we initially suspected AWS network drivers. We were able to rule this out by reproducing the issue using a different hypervisor, as well as on physical hardware.

We were also able to demonstrate that traffic wasn’t being discarded by another callout. We did this by implementing discard callouts so that our application would be notified about discard events (for example at the FWPM_LAYER_INBOUND_TRANSPORT_V4_DISCARD layer), as well as inspecting WFP traces.

Things were complicated further during our investigation when we noticed that sometimes, in some environments, for reasons that we didn’t understand, our callouts would work as expected and receive both outbound and inbound traffic. Our suspicion of a WFP bug grew!

Fast and normal paths

In WFP, there are two “paths” per “direction” for packets to follow: the normal path and the fast path. In total this means there are 4 paths – normal send, fast send, normal receive and fast receive. To try and keep things simple, from here on I’ll only refer to the fast and normal paths.

The normal path includes functionality to deliver packets and WFP events to callouts, including modifying packet data and behaviour in response to callout results. The fast path, on the other hand, excludes this functionality and in so doing avoids some of the processing overhead that it creates. As a result, the fast path is able to provide higher maximum throughput.

The kernel is supposed to automatically select the right path for a given flow. It does this based on its knowledge of which registered callouts would need to be called for the flow – a flow with no eligible callouts can be processed on the fast path, where the callout functionality (and associated overhead) is excluded.

When we looked more closely at the behaviour of WFP through debug tracing when the callouts worked as expected, we noticed an explicit mention of the normal path decision:

TCPIP: SendDatagram: 0xFFFF8067DE64500 fell off the send fast path, 
Reason: WFP filters present.

This mention was absent in traces for test runs that suffered from the incorrect behaviour. For some reason, our callout registrations appeared to be failing to trigger the kernel to move eligible flows off the fast path.

We wondered if this was down to a bug in the layers we were using. Could we confirm this by registering no-op callouts (callout functions which returned immediately without doing anything) at additional layers? The idea was that these additional layers would do the job of knocking traffic off the fast path on behalf of the buggy layers we were interested in.

We designed a test to do this, picking the additional layers carefully to ensure that they would apply to the same flows as the layers we cared about. The results were pretty promising: we saw both our previously faulty callouts now receiving both inbound and outbound packets, as well as the WFP trace confirming that all the expected flows were falling off the fast path.

Workaround and fix

From what we could tell from our testing, there was a negligible cost to the workaround we found of using additional callout registrations to force flows required by our main callouts off the fast path.

However, we were conscious of making too many assumptions about the inner workings of the kernel. We were also aware of the possibility that while the workaround might be sufficient for our purposes, it might not work for others. For example, there may be some cases where it’s not possible to use a callout registration at one layer to force exactly the right target traffic off the fast path for a different callout registration at a different layer.

With this in mind we raised the issue with Microsoft and sent them our findings. After some investigation, they confirmed that we had discovered a code defect in the way WFP qualifies traffic for the fast send path. WFP wasn’t checking for the presence of callouts using the FWPM_LAYER_STREAM_PACKET_V4/6 layers when deciding whether to use the fast or normal send path.

This meant that callouts using these layers would not get called unless another callout (either from the same application or a different one) was forcing use of the normal path. This explains why our callouts would sometimes work – another application was sometimes knocking traffic off the fast path with callouts registered for a different layer which didn’t suffer from this bug.

If you’ve been paying particular attention, you may be wondering why we were able to see outbound handshake packets if the issue was caused by the kernel incorrectly selecting the fast send path. I don’t know, and I think to answer this question requires internal knowledge of WFP that I don’t have. Microsoft didn’t address it directly in the investigation we did with them but if I were to guess, I would say that callout invocations for handshake packets are triggered by TCP connection state changes observable by WFP rather than packets traversing the network stack (which handshake packets may well not do in the same way as packets in established connections).

In October 2022, Microsoft confirmed that this issue was fixed in Windows 11 and Windows Server 2022.

SenseOn’s endpoint software acts as a distributed NDR (network detection and response) as well as an EDR (endpoint detection and response). Get the best security visibility from software alone – no physical on-premise servers or expensive cloud infrastructure required. Find out more.