Windows Filtering Platform internals - Reverse Engineering the callout mechanism
Intro
The Windows Filtering Platform (WFP) is a framework designated for host-based network traffic filtering, replacing the older NDIS and TDI filtering capabilities. WFP exposes both UM and KM apis, offering the ability to block, permit or aduit network traffic based on conditions or deep packet inspection (through “callouts”). As you might have guessed, WFP is leveraged by the Windows Firewall, Network filters of security products and with a tiny bit of creativity can be used for offensive purposes such as in rootkits. This blogpost is going to dive into how WFP callouts are managed by the kernel, and use our knowledge to suggest ways to evade components that leverage WFP.
The provided sources
Before we jump right into it, about the blogpost’s repo:
WFPEnumUM
and WFPEnumDriver
can be used to enumerate all registered callouts on the system (including their actual addresses, to use just load the driver and run the client).
WFPCalloutDriver
is a PoC callout driver (mainly used it for debugging but you can have a look to see the registration process)
Starting with the terminology
There are four terms used heavily across the WFP documentation:
-
Layers - used to categorize the type of network traffic to be evaluated, identified by a GUID and represent a location in the network processing stack. For example, you can attach on layer
FWPM_LAYER_INBOUND_TRANSPORT_V4
to filter packets just after their transport header has been parsed by the network stack, but before any additional transport layer processing takes place. -
Filters - Constructed from conditions (source port, ip etc…) and actions (permit, block, callout unknown, callout inspection and callout terminating). When the action is callout, and if the filter’s conditions match, the filter engine will call the filter’s registered driver callback, providing it with the opportunity to inspect the packet’s content. A callout may return permit, block or continue. If the action is callout terminating, it may return only permit or block, if it’s callout inspection - it should only return continue, and for callout unknown - the callout may act as terminating or not based on the result of the classification, there are no guarantees.
-
Sublayers - A way to logically group filters. Say you filter TCP traffic, and want to implement different filters for different ranges of ports, you can create two seperate sublayers for each range of ports.
-
Shims - A kernel component responsible for initating the classification process. That is, applying the correct filters to the packet and enforce the resulting action. The shim is called by
tcpip.sys
for each layer a packet arrives to at the network stack:
More terminology!
Filter arbitration is the logic implemented in WFP to decide the relations between different filters operating on the same layers, and essentially how it all works together. What I mean is, some ordering must be applied when processing filters. So let’s get familiar with a few more WFP terms:
-
Weight - Each filter has an assocciated weight value which defines the filter’s priority within a sublayer. Each sublayer has it’s own weight to define it’s priority within a layer. The shim processes an incoming packet by traversing sublayers from the one with the highest weight to the one with the lowest. A final decision is made only after all sublayers have been evaluated, allowing a multiple matching capability.
-
Filter arbitration - Refers to the process of constructing the list of matching filters ordered by weight and evaluating them until a either a filter returns permit or block, or until the list of filters is exausted. That is of course per sublayer.
-
Policy - As I said, within a layer all sublayers will be traversed regardless of whether a sublayer evaluated a deterministic action (e.g block, permit…). What if one sublayer returns
permit
and the other returnsblock
? The final decision is based on a well defined policy:- Actions are evaluated from high priotiy sublayers to lower priority sublayers.
- A block decision overrides a permit decision.
- A block decision is final. The packet will be discarded.
Understanding how callouts are managed internally
The more complex and signficant packet inspection logic is implemented by callouts. For those intrested in offensive security the ability to enumerate the registered callouts on the system, including their actual addresses (not offered by the WFP API), can be useful to evade them. For anyone else? Just a fun exercise!
Registration
A driver registers a callout with the filter engine using FwpsCalloutRegister
, with a structure describing the callout to be registered.
typedef struct FWPS_CALLOUT0_ {
GUID calloutKey;
UINT32 flags;
FWPS_CALLOUT_CLASSIFY_FN0 classifyFn;
FWPS_CALLOUT_NOTIFY_FN0 notifyFn;
FWPS_CALLOUT_FLOW_DELETE_NOTIFY_FN0 flowDeleteFn;
} FWPS_CALLOUT0;
- classifyFn - The callback function where the filtering logic is implemented.
- notifyFn - Called whenever a filter that references the callout is added or removed.
Another honorable mention is a flag named
FWP_CALLOUT_FLAG_CONDITIONAL_ON_FLOW
. As per MSDN: one more thing to note is a flag called FWP_CALLOUT_FLAG_CONDITIONAL_ON_FLOW , as MSDN says :"A callout driver can specify this flag when registering a callout that will be added at a layer that supports data flows. If this flag is specified, the filter engine calls the callout driver's classifyFn0 callout function only if there is a context associated with the data flow. A callout driver associates a context with a data flow by calling the FwpsFlowAssociateContext0 function."
Remember this one, we will come back to it later : )
Assocciating the callout with a filter and a layer
First, a driver must add the callout to a layer, by calling FwpmCalloutAdd
.
After the callout has been added, a driver must create filter that references the callout, by calling FwpmFilterAdd
.
- The latter can be done from Usermode. Generally speaking, a callout is registered with a GUID, and identified internally by the filter engine with a corresponding ID. An example callout driver to demonstrate the callout registration process is provided in the sources.
Registration - This time internally
Taking a look at FwpsCalloutRegister
you will observe the following sequence of calls:
fwpkclnt!FwpsCalloutRegister<X>
-> fwpkclnt!FwppCalloutRegister
-> NETIO!KfdAddCalloutEntry
-> NETIO!FeAddCalloutEntry
. The reversed version of NETIO!FeAddCalloutEntry
:
__int64 __fastcall FeAddCalloutEntry(
int a1,
__int64 ClassifyFunction,
__int64 NotifyFn,
__int64 FlowDeleteFn,
int Flags,
char a6,
unsigned int CalloutId,
__int64 DeviceObject)
{
__int64 v12; // rcx
__int64 CalloutEntry; // rdi
char v14; // bp
__int64 CalloutEntryPtr; // rbx
__int64 v16; // rax
CalloutEntry = WfpAllocateCalloutEntry(CalloutId);
if ( CalloutEntry )
goto LABEL_17;
v14 = 1;
CalloutEntryPtr = *(_QWORD *)(gWfpGlobal + 0x198) + 0x50i64 * CalloutId;
if ( !*(_DWORD *)(CalloutEntryPtr + 4) && !*(_DWORD *)(CalloutEntryPtr + 8) )
{
LABEL_6:
if ( !CalloutEntry )
goto LABEL_7;
LABEL_17:
WfpReportError(CalloutEntry, "FeAddCalloutEntry");
return CalloutEntry;
}
v16 = WfpReportSysErrorAsNtStatus(v12, "IsCalloutEntryAvailable", 0x40000000i64, 1i64);
CalloutEntry = v16;
if ( v16 )
{
WfpReportError(v16, "IsCalloutEntryAvailable");
goto LABEL_6;
}
LABEL_7:
memset(CalloutEntryPtr, 0i64, 0x50i64);
*(_DWORD *)CalloutEntryPtr = a1;
*(_DWORD *)(CalloutEntryPtr + 4) = 1;
if ( a1 == 3 )
*(_QWORD *)(CalloutEntryPtr + 40) = ClassifyFunction;
else
*(_QWORD *)(CalloutEntryPtr + 16) = ClassifyFunction;
*(_DWORD *)(CalloutEntryPtr + 48) = Flags;
*(_BYTE *)(CalloutEntryPtr + 73) = a6;
*(_QWORD *)(CalloutEntryPtr + 24) = NotifyFn;
*(_QWORD *)(CalloutEntryPtr + 32) = FlowDeleteFn;
*(_BYTE *)(CalloutEntryPtr + 72) = 0;
*(_WORD *)(CalloutEntryPtr + 74) = 0;
*(_DWORD *)(CalloutEntryPtr + 76) = 0;
if ( DeviceObject )
{
ObfReferenceObject(DeviceObject);
*(_QWORD *)(CalloutEntryPtr + 64) = DeviceObject;
}
if ( !dword_1C007D018 || !(unsigned __int8)tlgKeywordOn(&dword_1C007D018, 2i64) )
v14 = 0;
if ( v14 )
WfpCalloutDiagTraceCalloutAddOrRegister(CalloutId, CalloutEntryPtr);
return CalloutEntry;
}
TBC