Network Filter Drivers

Chapter 7

  • Network traffic is tied to the most common way for an attacker to gain initial access to a system. It’s also one of the key artifacts created when they perform lateral movement to jump from one host to another. If an endpoint security product wishes to capture and perform inspection on network packets, it’ll most likely implement some type of network filter driver.

Legacy Network Driver Interface Specification Drivers

  • There are many types of network drivers, most of which are backed by the Network Driver Interface Specification (NDIS). NDIS is a library that abstracts a device’s network hardware.

  • NDIS supports four types of drivers:

    1. Miniport - Manages a network interface card, such as by sending and receiving data. This is the lowest level of NDIS drivers.

    2. Protocol - Implements a transport protocol stack, such as TCP/IP. This is the highest level of NDIS drivers.

    3. Filter - Sits between miniport and protocol drivers to monitor and modify the interactions between the two subtypes.

    4. Intermediate - Sits between miniport and protocol drivers to expose both drivers’ entry points for communicating requests.

  • For the purposes of security monitoring, filter drivers work best, as they can catch network traffic at the lowest levels of the network stack, just before it is passed to the miniport and associated network interface card.

NDIS driver relationships
  • The biggest issue with NIDS is that they lack context and the metadata needed to provide valuable telemetry to the EDR agent. For this reason, EDRs nearly always use another framework: the Windows Filtering Platform (WFP).

The Windows Filtering Platform

  • WFP is a set of APIs and services for creating network-filtering applications, and it includes both user-mode and kernel-mode components.

  • The platform offers numerous benefits. It allows EDRs to filter traffic related to specific applications, users, connections, network interface cards, and ports. It supports both IPv4 and IPv6, provides boot-time security until the base filtering engine has started, and lets drivers filter, modify, and reinject traffic. It can also process pre- and post-decryption IPsec packets and integrates hardware offloading, allowing filter drivers to use hardware for packet inspection.

WFP architecture
  • Shims are kernel components that extract data and properties from the packet and pass them to the filter engine to start the process of applying filters.

  • The filter engine, sometimes called the generic filter engine to avoid confusion with the user-mode base filtering engine, performs filtering at the network and transport layers.

  • It contains layers of its own, which are containers used to organize filters into sets. Each of these layers, defined as GUIDs under the hood, has a schema that says what types of filters may be added to it. Layers may be further divided into sublayers that manage filtering conflicts. All layers inherit default sublayers, and developers can add their own.

  • Sublayers and filters are assigned a priority value, called a weight, that dictates the order in which they should be processed by the filter manager. This ordering logic is called filter arbitration.

  • During filter arbitration, filters evaluate the data parsed from the packet from highest to lowest priority to determine what to do with the packet. Each filter contains conditions and an action, just like common firewall rules (for example, “if the destination port is 4444, block the packet” or “if the application is edge.exe, allow the packet”).

  • The basic actions a filter can return are Block and Permit, but three other supported actions pass packet details to callout drivers: FWP_ACTION_CALLOUT_TERMINATING, FWP_ACTION _CALLOUT_INSPECTION, and FWP_ACTION_CALLOUT_UNKNOWN.

  • Callout drivers are third-party drivers that extend WFP’s filtering functionality beyond that of the base filters. These drivers provide advanced features such as deep-packet inspection, parental controls, and data logging. When an EDR vendor is interested in capturing network traffic, it typically deploys a callout driver to monitor the system.

  • Like basic filters, callout drivers can select the types of traffic that they’re interested in. When the callout drivers associated with a particular operation are invoked, they can suggest action be taken on the packet based on their unique internal processing logic.

  • When it ends, the result is returned to the shim, which acts on the final filtering decision.

Implementing a WFP Callout Driver

  • One of the first things the callout driver will do is open a session with the filter engine. To do this, the driver calls fltmgr!FwpmEngineOpen().

DWORD FwpmEngineOpen0(
        [in, optional] const wchar_t             *serverName,
        [in] UINT32                              authnService,
        [in, optional] SEC_WINNT_AUTH_IDENTITY_W *authIdentity,
        [in, optional] const FWPM_SESSION0       *session,
        [out] HANDLE                             *engineHandle);
  • The most notable argument passed to this function as input is authnService, which determines the authentication service to use. This can be RPC_C_AUTHN_WINNT or RPC_C_AUTHN_DEFAULT, both of which essentially just tell the driver to use NTLM authentication.

  • When this function completes successfully, a handle to the filter engine is returned through the engineHandle parameter and typically preserved in a global variable, as the driver will need it during its unloading process.

  • Next, the driver registers its callouts. This is done through a call to the fltmgr!FwpmCalloutRegister() API. Systems running Windows 8 or later will convert this function to fltmgr!FwpsCalloutRegister2().

NTSTATUS FwpsCalloutRegister2(
        [in, out] void           *deviceObject,
        [in] const FWPS_CALLOUT2 *callout,
        [out, optional] UINT32   *calloutId);
  • The pointer to the FWPS_CALLOUT2 structure passed as input to this function (via its callout parameter) contains details about the functions internal to the callout driver that will handle the filtering of packets.

typedef struct FWPS_CALLOUT2_ {
    GUID                                 calloutKey;
    UINT32                               flags;
    FWPS_CALLOUT_CLASSIFY_FN2            classifyFn;
    FWPS_CALLOUT_NOTIFY_FN2              notifyFn;
    FWPS_CALLOUT_FLOW_DELETE_NOTIFY_FN0  flowDeleteFn;
} FWPS_CALLOUT2;
  • The notifyFn and flowDeleteFn members are callout functions used to notify the driver when there is information to be passed related to the callout itself or when the data that the callout is processing has been terminated, respectively. The classifyFn member, however, is a pointer to the function invoked whenever there is a packet to be processed, and it contains the bulk of the logic used for inspection.

  • After we’ve defined the callout function, we can add it to the filter engine by calling fwpuclnt!FwpmCalloutAdd(), passing the engine handle obtained earlier and a pointer to an FWPM_CALLOUT structure.

typedef struct FWPM_CALLOUT0_ {
    GUID               calloutKey;
    FWPM_DISPLAY_DATA0 displayData;
    UINT32             flags;
    GUID               *providerKey;
    FWP_BYTE_BLOB      providerData;
    GUID               applicableLayer;
    UINT32             calloutId;
} FWPM_CALLOUT0;
  • This structure contains data about the callout, such as its optional friendly name and description in its displayData member, as well as the layers to which the callout should be assigned. When the function used by the driver to add its callout completes, it returns a runtime identifier for the callout that is preserved for use during unloading.

  • Unlike filter layers, a developer may add their own sublayers to the system. In those cases, the driver will call fwpuclnt!FwpmSublayerAdd(), which receives the engine handle, a pointer to an FWPM_SUBLAYER structure, and an optional security descriptor.

  • The structure passed as input includes the sublayer key, a GUID to uniquely identify the sublayer, an optional friendly name and description, an optional flag to ensure that the sublayer persists between reboots, the sublayer weight, and other members that contain the state associated with a sublayer.

  • The last action a callout driver performs is adding a new filter object to the system. This filter object is the rule that the driver will evaluate when processing the connection. To create one, the driver calls fwpuclnt!FwpmFilterAdd(), passing in the engine handle, a pointer to an FWPM_FILTER structure and an optional pointer to a security descriptor.

typedef struct FWPM_FILTER0_ {
  GUID                   filterKey;
  FWPM_DISPLAY_DATA0     displayData;
  UINT32                 flags;
  GUID                   *providerKey;
  FWP_BYTE_BLOB          providerData;
  GUID                   layerKey;
  GUID                   subLayerKey;
  FWP_VALUE0             weight;
  UINT32                 numFilterConditions;
  FWPM_FILTER_CONDITION0 *filterCondition;
  FWPM_ACTION0           action;
  union {
    UINT64 rawContext;
    GUID   providerContextKey;
  };
  GUID                   *reserved;
  UINT64                 filterId;
  FWP_VALUE0             effectiveWeight;
} FWPM_FILTER0;
  • The flags member contains several flags that describe attributes of the filter, such as whether the filter should persist through system reboots (FWPM_FILTER_FLAG_PERSISTENT) or if it is a boot-time filter (FWPM_FILTER_FLAG _BOOTTIME).

  • The weight member defines the priority value of the filter in relation to other filters.

  • The numFilterConditions is the number of filtering conditions specified in filterCondition, an array of FWPM_FILTER_CONDITION structures that describe all the filtering conditions. For the callout functions to process the event, all conditions must be true.

  • Lastly, action is an FWP_ACTION_TYPE value indicating what action to perform if all filtering conditions return true. These actions include permitting, blocking, or passing the request to a callout function.

  • Of these members, filterCondition is the most important, as each filter condition in the array represents a discrete “rule” against which the connections will be evaluated. Each rule is itself made up of a condition value and match type.

typedef struct FWPM_FILTER_CONDITION0_ {
    GUID fieldKey;
    FWP_MATCH_TYPE matchType;
    FWP_CONDITION_VALUE0 conditionValue;
} FWPM_FILTER_CONDITION0;
  • The first member, fieldKey, indicates the attribute to evaluate. Each filtering layer has its own attributes, identified by GUIDs.

  • The matchType member specifies the type of match to be performed. These comparison types are defined in the FWP_MATCH_TYPE enumeration and can match strings, integers, ranges, and other data types.

typedef enum FWP_MATCH_TYPE_ {
    FWP_MATCH_EQUAL = 0,
    FWP_MATCH_GREATER,
    FWP_MATCH_LESS,
    FWP_MATCH_GREATER_OR_EQUAL,
    FWP_MATCH_LESS_OR_EQUAL,
    FWP_MATCH_RANGE,
    FWP_MATCH_FLAGS_ALL_SET,
    FWP_MATCH_FLAGS_ANY_SET,
    FWP_MATCH_FLAGS_NONE_SET,
    FWP_MATCH_EQUAL_CASE_INSENSITIVE,
    FWP_MATCH_NOT_EQUAL,
    FWP_MATCH_PREFIX,
    FWP_MATCH_NOT_PREFIX,
    FWP_MATCH_TYPE_MAX
} FWP_MATCH_TYPE;
  • The last member of the structure, conditionValue, is the condition against which the connection should be matched. The filter condition value is composed of two parts, the data type and a condition value, housed together in the FWP_CONDITION_VALUE structure.

typedef struct FWP_CONDITION_VALUE0_ {
  FWP_DATA_TYPE type;
  union {
    UINT8                 uint8;
    UINT16                uint16;
    UINT32                uint32;
    UINT64                *uint64;
    INT8                  int8;
    INT16                 int16;
    INT32                 int32;
    INT64                 *int64;
    float                 float32;
    double                *double64;
    FWP_BYTE_ARRAY16      *byteArray16;
    FWP_BYTE_BLOB         *byteBlob;
    SID                   *sid;
    FWP_BYTE_BLOB         *sd;
    FWP_TOKEN_INFORMATION *tokenInformation;
    FWP_BYTE_BLOB         *tokenAccessInformation;
    LPWSTR                unicodeString;
    FWP_BYTE_ARRAY6       *byteArray6;
    FWP_V4_ADDR_AND_MASK  *v4AddrMask;
    FWP_V6_ADDR_AND_MASK  *v6AddrMask;
    FWP_RANGE0            *rangeValue;
  };
} FWP_CONDITION_VALUE0;
  • The FWP_DATA_TYPE value indicates what union member the driver should use to evaluate the data. For instance, if the type member is FWP_V4_ADDR_MASK, which maps to an IPv4 address, then the v4AddrMask member would be accessed.

  • The match type and condition value members form a discrete filtering requirement when combined. In callout drivers that perform firewalling activities, we could choose to permit or block traffic based on certain attributes. In the context of security monitoring, however, most developers forward the request to the callout functions by specifying the FWP_ACTION_CALLOUT_INSPECTION flag, which passes the request to the callout without expecting the callout to make a permit/deny decision regarding the connection.

Filtering conditions
  • In order to ensure that various filters are executed in order, weights are added to them. In code, a weight is just an FWP_VALUE (UINT8 or UINT64) assigned in the weight member of the FWPM_FILTER structure.

  • In addition to assigning the weight, we need to assign the filter to a sublayer so that it is evaluated at the correct time. We do this by specifying a GUID in the layerKey member of the structure. If we created our own sublayer, we would specify its GUID here. Otherwise, we’d use one of the default sublayer GUIDs.

Default Sublayer GUIDs
  • The FWPM_SUBLAYER_IPSEC_SECURITY_REALM sublayer identifier is defined in the fwpmu.h header but is undocumented.

  • The last parameter we can pass to fwpuclnt!FwpmFilterAdd() is a security descriptor. While optional, it allows the developer to explicitly set the access control list for their filter. Otherwise, the function will apply a default value to the filter.

  • This default security descriptor grants GenericAll rights to members of the Local Administrators group, and GenericRead, GenericWrite, and GenericExecute rights to members of the Network Configuration Operators group, as well as the diagnostic service host (WdiServiceHost), IPsec policy agent (PolicyAgent), network list service (NetProfm), remote procedure call (RpcSs), and Windows firewall (MpsSvc) services. Lastly, FWPM_ACTRL_OPEN and FWPM_ACTRL_CLASSIFY are granted to the Everyone group.

  • After the call to fwpuclnt!FwpmFilterAdd() completes, the callout driver has been initialized, and it will process events until the driver is ready to be unloaded.

Detecting Adversary Tradecraft

  • The bulk of the telemetry that a WFP filter driver collects comes from its callouts. These are most often classify callouts, which receive information about the connection as input.

  • In return, the callout function will set the action for the relevant shim to take, as well as an action for the filter engine to take, such as to block or allow the packet. It might also defer the decision-making to the next registered callout function.

FWPS_CALLOUT_CLASSIFY_FN2 FwpsCalloutClassifyFn2;

void FwpsCalloutClassifyFn2(
    [in] const FWPS_INCOMING_VALUES0 *inFixedValues,
    [in] const FWPS_INCOMING_METADATA_VALUES0 *inMetaValues,
    [in, out, optional] void *layerData,
    [in, optional] const void *classifyContext,
    [in] const FWPS_FILTER2 *filter,
    [in] UINT64 flowContext,
    [in, out] FWPS_CLASSIFY_OUT0 *classifyOut)
{...}
  • The first parameter, a pointer to an FWPS_INCOMING_VALUES structure contains information about the connection that has been passed from the filter engine to the callout.

typedef struct FWPS_INCOMING_VALUES0_ {
    UINT16 layerId;
    UINT32 valueCount;
    FWPS_INCOMING_VALUE0 *incomingValue;
} FWPS_INCOMING_VALUES0;
  • The first member of this structure contains the identifier of the filter layer at which the data was obtained. These values are defined by Microsoft.

  • The second member contains the number of entries in the array pointed to by the third parameter, incomingValue. This is an array of FWPS _INCOMING_VALUE structures containing the data that the filter engine passes to the callout. Each structure in the array has an FWP_VALUE structure.

typedef struct FWP_VALUE0_ {
    FWP_DATA_TYPE type;
    union {
             UINT8 uint8;
             UINT16 uint16;
             UINT32 uint32;
             UINT64 *uint64;
             INT8 int8;
             INT16 int16;
             INT32 int32;
             INT64 *int64;
             float float32;
             double *double64;
             FWP_BYTE_ARRAY16 *byteArray16;
             FWP_BYTE_BLOB *byteBlob;
             SID *sid;
             FWP_BYTE_BLOB *sd;
             FWP_TOKEN_INFORMATION *tokenInformation;
             FWP_BYTE_BLOB *tokenAccessInformation;
             LPWSTR unicodeString;
             FWP_BYTE_ARRAY6 *byteArray6;
         };
} FWP_VALUE0;
  • To access the data inside the array, the driver needs to know the index at which the data resides. This index varies based on the layer identifier being processed. For instance, if the layer is FWPS_LAYER_OUTBOUND_IPPACKET_V4, the driver would access fields based on their index in the FWPS_FIELDS _OUTBOUND_IPPACKET_V4 enumeration.

typedef enum FWPS_FIELDS_OUTBOUND_IPPACKET_V4_ {
        FWPS_FIELD_OUTBOUND_IPPACKET_V4_IP_LOCAL_ADDRESS,
        FWPS_FIELD_OUTBOUND_IPPACKET_V4_IP_LOCAL_ADDRESS_TYPE,
        FWPS_FIELD_OUTBOUND_IPPACKET_V4_IP_REMOTE_ADDRESS,
        FWPS_FIELD_OUTBOUND_IPPACKET_V4_IP_LOCAL_INTERFACE,
        FWPS_FIELD_OUTBOUND_IPPACKET_V4_INTERFACE_INDEX,
        FWPS_FIELD_OUTBOUND_IPPACKET_V4_SUB_INTERFACE_INDEX,
        FWPS_FIELD_OUTBOUND_IPPACKET_V4_FLAGS,
        FWPS_FIELD_OUTBOUND_IPPACKET_V4_INTERFACE_TYPE,
        FWPS_FIELD_OUTBOUND_IPPACKET_V4_TUNNEL_TYPE,
        FWPS_FIELD_OUTBOUND_IPPACKET_V4_COMPARTMENT_ID,
        FWPS_FIELD_OUTBOUND_IPPACKET_V4_MAX
} FWPS_FIELDS_OUTBOUND_IPPACKET_V4;
  • If an EDR’s driver wanted to inspect the remote IP address, it could access this value using the code:

if (inFixedValues->layerId == FWPS_LAYER_OUTBOUND_IPPACKET_V4)
{
    UINT32 remoteAddr = inFixedValues->
      incomingValues[FWPS_FIELD_OUTBOUND_IPPACKET_V4_IP_REMOTE_ADDRESS].value.uint32;
        
    --snip--
}
  • The next parameter that the callout function receives is a pointer to an FWPS_INCOMING_ METADATA_VALUES0 structure, which provides incredibly valuable metadata to an EDR.

typedef struct FWPS_INCOMING_METADATA_VALUES0_ {
  UINT32                          currentMetadataValues;
  UINT32                          flags;
  UINT64                          reserved;
  FWPS_DISCARD_METADATA0          discardMetadata;
  UINT64                          flowHandle;
  UINT32                          ipHeaderSize;
  UINT32                          transportHeaderSize;
  FWP_BYTE_BLOB                   *processPath;
  UINT64                          token;
  UINT64                          processId;
  UINT32                          sourceInterfaceIndex;
  UINT32                          destinationInterfaceIndex;
  ULONG                           compartmentId;
  FWPS_INBOUND_FRAGMENT_METADATA0 fragmentMetadata;
  ULONG                           pathMtu;
  HANDLE                          completionHandle;
  UINT64                          transportEndpointHandle;
  SCOPE_ID                        remoteScopeId;
  WSACMSGHDR                      *controlData;
  ULONG                           controlDataLength;
  FWP_DIRECTION                   packetDirection;
  PVOID                           headerIncludeHeader;
  ULONG                           headerIncludeHeaderLength;
  IP_ADDRESS_PREFIX               destinationPrefix;
  UINT16                          frameLength;
  UINT64                          parentEndpointHandle;
  UINT32                          icmpIdAndSequence;
  DWORD                           localRedirectTargetPID;
  SOCKADDR                        *originalDestination;
  HANDLE                          redirectRecords;
  UINT32                          currentL2MetadataValues;
  UINT32                          l2Flags;
  UINT32                          ethernetMacHeaderSize;
  UINT32                          wiFiOperationMode;
  NDIS_SWITCH_PORT_ID             vSwitchSourcePortId;
  NDIS_SWITCH_NIC_INDEX           vSwitchSourceNicIndex;
  NDIS_SWITCH_PORT_ID             vSwitchDestinationPortId;
  UINT32                          padding0;
  USHORT                          padding1;
  UINT32                          padding2;
  HANDLE                          vSwitchPacketContext;
  PVOID                           subProcessTag;
  UINT64                          reserved1;
} FWPS_INCOMING_METADATA_VALUES0;
  • Note that not all values in this structure will be populated. To see which values are present, the callout function checks the currentMetadata Values member, which is a bitwise-OR of a combination of metadata filter identifiers. Microsoft nicely provided us with a macro, FWPS_IS_METADATA_FIELD _PRESENT(), that will return true if the value we’re interested in is present.

  • After the metadata, the classify function receives information about the layer being filtered and the conditions under which the callout is invoked. This layer data contains a pointer to an FWPS_STREAM_DATA0 structure, which contains flags that encode the characteristics of the stream (for example, whether it is inbound or outbound, whether it is high priority, and whether the network stack will pass the FIN flag in the final packet). It will also contain the offset to the stream, the size of its data in the stream, and a pointer to a NET_BUFFER_LIST that describes the current portion of the stream.

  • This buffer list is a linked list of NET_BUFFER structures. Each structure in the list contains a chain of memory descriptor lists used to hold the data sent or received over the network.

  • The layer data structure also contains a streamAction member, which is an FWPS_STREAM_ACTION_TYPE value describing an action that the callout recommends the stream-layer shim take. These include:

    1. FWPS_STREAM_ACTION_NONE - Do nothing

    2. FWPS_STREAM_ACTION_ALLOW_CONNECTION - Allow all future data segments in the flow to continue without inspection.

    3. FWPS_STREAM_ACTION_NEED_MORE_DATA - If this is set, the callout must populate the countBytesRequired member with the number of bytes of stream data required.

    4. FWPS_STREAM_ACTION_DROP_CONNECTION - Drop the connection.

    5. FWPS_STREAM_ACTION_DEFER - Defer processing until fwpkclnt!FwpsStreamContinue0() is called. This is used for flow control, to slow down the rate of incoming data.

When it comes to evading WFP callout drivers, there aren’t many options. In a lot of ways, evading network filters is very similar to performing a standard firewall rule assessment. Some filters may opt to explicitly permit or deny traffic, or they may send the contents off for inspection by a callout. Thus redteamers must enumerate the various filters in the system and modify their tradecraft accordingly.

Last updated