WNF_1

WNF Chronicles I: Introduction

Windows Notification Facility (WNF) is a mostly undocumented Windows Kernel component internally used to send notifications accross the system. In this blog series I will cover the internals of WNF and how can it be (ab)used to perform different tasks like Process Injection or Data Persistence.

Introduction

I decided to write this series after struggling to understand the internals of WNF for my current job. I needed to RE a Windows functionality which under the hood was using this subsystem I never heard about before. There was almost none information about it, but the few blog posts covering this issue were gold for me. So I wanted to contribute and help future people struggling with the same issue.

Also, from my Red Team experience and perspective, I really enjoy trying to think outside the box and thinking about new ways of doing interesting stuff, mainly to avoid detections. So in this series I will also show you some known (and new) techniques that abuse WNF to perform different offensive-related tasks.

References and Inspiration

As I mentioned before, information about WNF on the Internet is really scarce. However, there are a few references that really helped me.

What is WNF?

WNF is a pub-sub notification dispatching system that can be accessed from User and Kernel modes using a set of undocumented APIs and structures. Processes can subscribe to different events, which are identified by StateNames. Subscribed processes will be notified each time an update occurs in the StateNames’ data. A process can also publish data associated with a StateName, so every subscribed process will be notified and will receive this new data.

We can think of WNF as a mailbox with capacity for a single message. The mailbox itself will be located inside the Kernel, which means the StateData (data that has been published for a specific StateName) resides inside the Kernel memory pool. Every time a StateName is updated, the previous StateData is overwritten and the ChangeStamp (counter that reflects the number of times a StateName has been updated) will be increased.

WNF can be imagined as a mailbox.

When a process subscribes to an StateName, it declares a Callback that will be executed in a new thread each time this specific StateName is updated. Each process will store information for its subscriptions inside its own memory, using different data structures that we will explain in a while. But for now, you just need to understand that when a process is notified about a StateName update, it will walk its own memory looking for different Name Subscriptions (a list of subscriptions for a single StateName) and, for each one of them, it will execute the declared callback.

However, using the NtQueryWnfStateData API, any process can access the latest StateData published for a given StateName at any time (as long as it has read privileges over the StateName’s ACL), without being subscribed.

Also Kernel drivers can subscribe to StateNames and declare Callbacks to be executed.

StateNames

StateNames are identifiers for WNF notifications/data. Each StateName will indentify a Kernel struct, allocated inside the Kernel pool, which contains information about Scope, ChangeStamp, Subscriptors and StateData, among other things.

Essentially, StateNames are 64-bit integers, with the peculiarity that they hide a struct. For some reason, if you XOR the 64-bit integer with 0x41C64E6DA3BC0074 the result will be the following struct:

typedef struct {
    unsigned int Version:4;
    _WNF_STATE_NAME_LIFETIME NameLifetime:2;
    _WNF_DATA_SCOPE DataScope:4;
    int PermanentData:1;
    unsigned long Sequence:53;
} _WNF_STATE_NAME_STRUCT;

The following image is a fragment of the decompiled function NtCreateWnfStateName from ntoskrnl.exe where you can see the actual XOR-ing process.

Both _WNF_STATE_NAME_LIFETIME and _WNF_DATA_SCOPE enums are:

typedef enum {
    WnfDataScopeSystem = 0,
    WnfDataScopeSession = 1,
    WnfDataScopeUser = 2, 
    WnfDataScopeProcess = 3,
    WnfDataScopeMachine = 4,
    WnfDataScopePhysicalMachine = 5
} _WNF_DATA_SCOPE;   

typedef enum {
    WnfWellKnownStateName = 0,
    WnfPermanentStateName = 1,
    WnfPersistentStateName = 2, 
    WnfTemporaryStateName = 3
} _WNF_STATE_NAME_LIFETIME;

All these structs and enums are accessible via NTDLL symbols using WinDBG:

StateName structs and enums in WinDBG

StateName Lifetime

The Lifetime property specifies exactly that: when a StateName is going to be removed. However, there are not two types of Lifetimes but four: Well-Known, Permanent, Persistent and Temporary.

Well-Known StateNames are those predefined by Windows developers. A list of this StateNames can be found in the RegistryKey HKLM\SYSTEM\CurrentControlSet\Control\Notifications. It was found that there is a system DLL mapping some Well-Known StateNames with human-readable names: C:\Windows\System32\ContentDeliveryManager.Utilities.dll

This DLL also contains a brief description which can be useful for understanding what is a StateName used for.

These Well-Known StateNames are actively used in both Windows10 and Windows11 and should not be modified. They are always registered, and a user cannot create new ones.

Permanent StateNames are also always registered, which means they persist across system reboots. These are conceptually equivalent to Well-Known ones, but privileged users can register new StateNames. They are stored in HKLM\SOFTWARE\Microsoft\Windows NT\ CurrentVersion\Notifications. Persisted data is stored in HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Notifications\Data

Persistent StateNames, despite of their name, are not persistent, meaning they will not persist across system reboots. Instead, they will be registered until a system reboot occurs. Also privileged users can register new Persistent StateNames, which are stored in HKLM\SOFTWARE\Microsoft\WindowsNT\CurrentVersion\VolatileNotifications

Temporary StateNames are the only ones an unprivileged user can register. They will have the same lifetime as the parent process, so they will disappear if the process dies.

Taking a look again into NtCreateWnfStatename, we can see that the SeCreatePermanentPrivilege is needed in order to create both Permanent and Persistent StateNames, but not for Temporary ones (NameLifetime == 3).

Data Scopes

Data Scopes are a visibility boundary for StateNames. Valid scopes are System, Session, User, Process, Machine or Physical Machine. If a StateName is created with the Session Data Scope, only processes running under the same session will have visibility over this StateName.

Security Descriptors

Upon creation, StateNames are provided with a Security Descriptor. This descriptor is stored inside the StateNameInfo property of the _WNF_NAME_INSTANCE kernel object (the object representing all the information associated with an specific StateName), as we will see in a while. In the case of those StateNames stored in the Registry, the SecurityDescriptor is also stored there. The following image shows the contents of the Well-Known StateNames Registry Key.

Every time a process tries to query (read) or publish/update (write) a StateData, the Security Descriptor associated with the specified StateName will be checked for permissions.

As long as you can read the Security Descriptor from Registry, it is possible to understand which actions an specific user can perform. However, from a non-privileged process perspective, the best approach is to try and read/write in order to know if you have read/write access to an specific StateName.

Taking a look into NtQueryWnfStateData Kernel implementation (the API responsible for querying the StateData from a StateName), we can see that, in the case of Temporary StateNames, the Security Descriptor is retrieved from the _WNF_NAME_INSTANCE. However, non-temporary StateNames’ Security Descriptor is retrieved from Registry.

Kernel Structures

In order to understand how WNF works, we need to dive into the set of internal structures. All of them are undocumented officially, but they are declared in ntoskrnl symbols file, so we can check them using WinDBG.

Node Headers

Most of the WNF structs start with a Header property which can be used to recognize which struct are we seeing.

The Header struct contains both a TypeCode used to identify each WNF struct, and a ByteSize, used to get the actual size of the struct.

Here are some TypeCodes we will be seeing:

#define WNF_SCOPE_MAP_CODE          ((CSHORT)0x901)
#define WNF_SCOPE_INSTANCE_CODE     ((CSHORT)0x902)
#define WNF_NAME_INSTANCE_CODE      ((CSHORT)0x903)
#define WNF_STATE_DATA_CODE         ((CSHORT)0x904)
#define WNF_SUBSCRIPTION_CODE       ((CSHORT)0x905)
#define WNF_PROCESS_CONTEXT_CODE    ((CSHORT)0x906)

Scope Instances

Since Windows10, the concept of server silos was introduced, which I will not explain here. What I want to remark here is that each silo has its own Object Manager namespace, and even if no server silos are running, there is a host silo considered to be the global silo.

We can check the host silo globals by using !silo -g host and clicking on Server silo globals.

This WnfSiloState global points to a _WNF_SILODRIVERSTATE struct:

The first property of this struct points to a _WNF_SCOPE_MAP, which is another struct that contains pointers to different Scope Instances, represented by the _WNF_SCOPE_INSTANCE struct. Clicking on the ScopeMap property inside the _WNF_SILODRIVERSTATE struct, we can see the following Scope Map:

A Scope Instance represents a Data Scope (the scope for a StateName as we saw before) and also contains all the Name Instances (which, as I said before, contains all the information about a StateName):

The previous image shows the System Scope Instance, as can be seen in the DataScope property. The NameSet property points to a binary tree with all the Name Instances within the System Scope.

The following image shows that the NameSet property points to the root of a binary tree. Each node from the binary tree points to the TreeLinks property of a _WNF_NAME_INSTANCE, so in order to get the base address of the actual _WNF_NAME_INSTANCE we need to substract 0x10.

Name Instances

A Name Instance is the actual instance of a WNF StateName, represented in memory by the _WNF_NAME_INSTANCE struct, with a TypeCode 0x903.

//0xa8 bytes (sizeof)
struct _WNF_NAME_INSTANCE
{
    struct _WNF_NODE_HEADER Header;                                         //0x0
    struct _EX_RUNDOWN_REF RunRef;                                          //0x8
    struct _RTL_BALANCED_NODE TreeLinks;                                    //0x10
    struct _WNF_STATE_NAME_STRUCT StateName;                                //0x28
    struct _WNF_SCOPE_INSTANCE* ScopeInstance;                              //0x30
    struct _WNF_STATE_NAME_REGISTRATION StateNameInfo;                      //0x38
    struct _WNF_LOCK StateDataLock;                                         //0x50
    struct _WNF_STATE_DATA* StateData;                                      //0x58
    ULONG CurrentChangeStamp;                                               //0x60
    VOID* PermanentDataStore;                                               //0x68
    struct _WNF_LOCK StateSubscriptionListLock;                             //0x70
    struct _LIST_ENTRY StateSubscriptionListHead;                           //0x78
    struct _LIST_ENTRY TemporaryNameListEntry;                              //0x88
    struct _EPROCESS* CreatorProcess;                                       //0x98
    LONG DataSubscribersCount;                                              //0xa0
    LONG CurrentDeliveryCount;                                              //0xa4
}; 

The TreeLinks property points to a balanced node with the right and left children of the current node.

The StateName property points to a _WNF_STATE_NAME_STRUCT, which is the same struct we saw at the beginning of this post as a result of XOR-ing the StateName.

The StateData property points to a _WNF_STATE_DATA struct containing the actual State Data for this specific WNF Name.

The data buffer is allocated right after the _WNF_STATE_DATA struct, so it will be located at StateData + 0x10. The following image shows the pool allocation for the State Data. As you can see, the allocated size is 16 + MaxStateSize (the maximum size of the State Data buffer).

The PermanentDataStore is a _WNF_PERMANENT_DATA_STORE object which contains information about the Registry Key used to stored persisted data. This way, the data will be accessible accross system reboots. It has a Handle field which is a Registry Key handle.

The StateSubscriptionListHead property points to a double-linked list of _WNF_SUBSCRIPTIONS, so we can walk this list to find all the subscriptions for this specific StateName.

The TemporaryNameListEntry property is another double-linked list pointing to the next Temporary Name _WNF_NAME_INSTANCE object. This is only used if the current Name Instance belongs to a Temporary Name.

The CreatorProcess will point to the EPROCESS struct of the creator process.

There are of course other properties, but for now I think these few are more than enough to understand how everything works.

Subscriptions

A subscription object stores all the information associated with a WNF Subscription. It is represented by the following struct:

//0x88 bytes (sizeof)
struct _WNF_SUBSCRIPTION
{
    struct _WNF_NODE_HEADER Header;                                         //0x0
    struct _EX_RUNDOWN_REF RunRef;                                          //0x8
    ULONGLONG SubscriptionId;                                               //0x10
    struct _LIST_ENTRY ProcessSubscriptionListEntry;                        //0x18
    struct _EPROCESS* Process;                                              //0x28
    struct _WNF_NAME_INSTANCE* NameInstance;                                //0x30
    struct _WNF_STATE_NAME_STRUCT StateName;                                //0x38
    struct _LIST_ENTRY StateSubscriptionListEntry;                          //0x40
    ULONGLONG CallbackRoutine;                                              //0x50
    VOID* CallbackContext;                                                  //0x58
    ULONG CurrentChangeStamp;                                               //0x60
    ULONG SubscribedEventSet;                                               //0x64
    struct _LIST_ENTRY PendingSubscriptionListEntry;                        //0x68
    enum _WNF_SUBSCRIPTION_STATE SubscriptionState;                         //0x78
    ULONG SignaledEventSet;                                                 //0x7c
    ULONG InDeliveryEventSet;                                               //0x80
}; 

As we mentioned before, the header TypeCode is 0x905. This struct has interesting information, but we will only focus on the most relevant properties.

Each subscription has a per-silo unique SubscriptionId. Subscriptions can be found via ProcessSubscriptionListEntry (double-linked list pointing to per-process _WNF_SUBSCRIPTION structs) or via StateSubscriptionListEntry (double-linked list pointing to per-NameInstance structs). Right now we have reached this object from a Name Instance, walking the StateSubscriptionList, but each EPROCESS will point to the ProcessSubscriptionList, which is another way of iterating through different process subscriptions.

CallbackRoutine and CallbackContext are the most interesting fields here. The CallbackRoutine is the address (within process memory) of the actual callback to be executed whenever the StateName is updated. The CallbackContext is the context to be passed to the callback. This context can have different forms, but usually will be a vtable with methods to be conditionally executed (depending on the StateName, StateData, calling process…).

Process Context

As we mentioned before, each EPROCESS object will have information about the WNF states it is subscribed to. The property WnfContext points to a _WNF_PROCESS_CONTEXT struct (TypeId 0x906) which contains this specific information.

//0x88 bytes (sizeof)
struct _WNF_PROCESS_CONTEXT
{
    struct _WNF_NODE_HEADER Header;                                         //0x0
    struct _EPROCESS* Process;                                              //0x8
    struct _LIST_ENTRY WnfProcessesListEntry;                               //0x10
    VOID* ImplicitScopeInstances[3];                                        //0x20
    struct _WNF_LOCK TemporaryNamesListLock;                                //0x38
    struct _LIST_ENTRY TemporaryNamesListHead;                              //0x40
    struct _WNF_LOCK ProcessSubscriptionListLock;                           //0x50
    struct _LIST_ENTRY ProcessSubscriptionListHead;                         //0x58
    struct _WNF_LOCK DeliveryPendingListLock;                               //0x68
    struct _LIST_ENTRY DeliveryPendingListHead;                             //0x70
    struct _KEVENT* NotificationEvent;                                      //0x80
}; 

This Process Context contains pointers to the next Process Context (WnfProcessesListEntry – list of _WNF_PROCESS_CONTEXT), to the head of the process’ Temporary Names list (TemporaryNamesListHead – list of _WNF_NAME_INSTANCE) and to the head of the Process Subscription’s list (ProcessSubscriptionListHead – list of _WNF_SUBSCRIPTION).

Using this structure we can understand which WNF States is the process subscribed to, and which temporary States have been created by this process.

nt!ExpWnfProcessesListHead points to the head of the WnfProcessesList, which is a double-linked list containing all the _WNF_PROCESS_CONTEXT. So it is also possible to iterate over this structs using such global, but always remember it points to _WNF_PROCESS_CONTEXT + 0x10, so you need to substract 0x10.

Userland Structures

Now we understand Kernel structures used by WNF and how is it possible to locate and store information about all the StateNames, StateData and Subscriptions. But how is all of this reflected into Userland?

Structures defined from now on are not defined inside any symbols file or public documentation. Instead, they have been reverse engineered, so they may contain errors. Feel free to reach me for any error you may find 🙂
Of course, I based my research on the previous work made by @pwissenlit, @modexpblog and @aionescu. However, userland structs have evolved since then and are not the same as they show in their publications, so I decided to RE NTDLL and write my own version of these structs.
Also note that all the structs are for Win11x64 instances, since Win10 structs have different fields and 32-bit system will have different pointer sizes.

Multiplexing Event Handlers

Going back to the _WNF_PROCESS_CONTEXT object, we may notice that only a single NotificationEvent is used. This effectively means that only a single Event can be associated with a process.

So how a single process can handle multiple WNF consumers? The answer is the process stores information about all its WNF Subscriptions in what is called a Subscription Table (stored in the process heap). The process’ associated Event will be signaled every time one of its subscribed StateNames is updated, but instead of executing the specific WNF Callback, it will run a multiplexor code (managed by NTDLL) which will walk the Subscription Table looking for all the Subscriptions for this specific StateName, adding all the different Callbacks to a queue and executing them in a single thread.

In order to store all this information, there are three main structures involved: Subscription Table, Name Subscription and User Subscription. A User Subscription stores information about the Callback to be executed. However, a developer may want to execute two different Callbacks as a result of a single notification, so that’s why Name Subscriptions exist. A Name Subscription contains a list of User Subscriptions for the same State Name. Finally, the Subscription Table contains a list of Name Subscriptions, one for each different StateName the process is subscribed to.

Node Headers

As happened with Kernel structs, Userland structs also contain a _WNF_NODE_HEADER field at the beginning, making it easy to find its implementation in memory.

#define WNF_SUBSCRIPTION_TABLE_CODE    ((CSHORT)0x911)
#define WNF_NAME_SUBSCRIPTION_CODE     ((CSHORT)0x912)
#define WNF_SERIALIZATION_GROUP_CODE   ((CSHORT)0x913)
#define WNF_USER_SUBSCRIPTION_CODE     ((CSHORT)0x914)

Subscription Table

The Subscription Table is represented in memory by the following _WNF_SUBSCRIPTION_TABLE struct. I have not RE’d all the fields, but the most relevant ones are shown here.

//0x60 bytes (sizeof)
struct _WNF_SUBSCRIPTION_TABLE_WIN11
{
    struct _WNF_NODE_HEADER Header;                                         //0x0
    ...
    SRWLOCK NamesTableLock;                                                 //0x8
    struct _RTL_RB_TREE NamesTableEntry;                                    //0x10
    struct _LIST_ENTRY SerializationGroupListHead;                          //0x20
    SRWLOCK  SerializationGroupListLock;                                    //0x30
    ...
}; 

The NamesTableEntry Binary Tree contains references to all the Name Subscription for this process.

Name Subscriptions

Name Subscriptions are represented by the following _WNF_NAME_SUBSCRIPTION struct.

//0xA0 bytes (sizeof)
struct _WNF_NAME_SUBSCRIPTION_WIN11
{
    struct _WNF_NODE_HEADER Header;                                         //0x0
    ...
    ulong StateName;                                                        //0x10
    QWORD ChangeStamp;                                                      //0x18
    struct RTL_BALANCED_NODE NamesTableEntry;                               //0x20
    ...
    struct LIST_ENTRY SubscriptionsListHead;                                //0x48
    ...
}; 

This struct contains the StateName of the subscription, the ChangeStamp and a field called NamesTableEntry, which is a LIST_ENTRY pointing to the previous and next Name Subscriptions. It also contains a field called SubscriptionsListHead, pointing to the head of a double-linked list of User Subscriptions.

User Subscriptions

User Subscriptions are represented by the followint _WNF_USER_SUBSCRIPTION struct:

//0xA0 bytes (sizeof)
struct _WNF_USER_SUBSCRIPTION_WIN11
{
    struct _WNF_NODE_HEADER Header;                                         //0x0
    ...
    struct LIST_ENTRY SubscriptionsListEntry;                               //0x8
    _WNF_NAME_SUBSCRIPTION* pNameSubscription;                              //0x18
    void* Callback;                                                         //0x20
    void* CallbackContext;                                                  //0x28
    ulong SubProcessTag;                                                    //0x30
    int ChangeStamp;                                                        //0x28
    ...
    _WNF_SERIALIZATION_GROUP* pSerializationGroup;                                              //0x48  
    int UserSubscriptionsCount;                                             //0x50
    ...
}; 

The SubscriptionsListEntry field points to the previous and next User Subscriptions and the pNameSubscription points to the parent Name Subscription. However, the most relevant information about this struct are the Callback and the CallbackContext pointers.

Once the process’ Event is signaled, NTDLL’s dispatcher will walk the Subscription Table looking for all the User Subscriptions for the specific StateName and execute all the Callbacks in order.

WNF Userland API

In order to interact with the WNF subsystem from Userland, there is a NTDLL API we can use, but also some higher-level RTL methods.

Subscribing to WNF Events

The expected way for a process to subscribe to WNF Events is via RtlSubscribeWnfStateChangeNotification. The reason we are not directly using NTDLL syscalls is because this higher level method is also responsible for initializating the process’ Subscription Table in order to be able to subscribe to multiple WNF Events.

extern "C"
NTSTATUS
NTAPI
RtlSubscribeWnfStateChangeNotification(
    _Outptr_ PWNF_USER_SUBSCRIPTION* Subscription,
    _In_ WNF_STATE_NAME StateName,
    _In_ WNF_CHANGE_STAMP ChangeStamp,
    _In_ PWNF_USER_CALLBACK Callback,
    _In_opt_ PVOID CallbackContext,
    _In_opt_ PCWNF_TYPE_ID TypeId,
    _In_opt_ ULONG SerializationGroup,
    _In_opt_ ULONG Unknown
);

The Callback must have the following signature:

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
);

When this method is called, the first thing that happens is that RtlpInitializeWnf is called, which initializes the Subscription Table if it already wasn’t.

The RtlpInitializeWnf method also calls RtlpWnfRegisterTpNotification which effectively creates and registers the Event object.

The call to NtSetWnfProcessNotificationEvent will be responsible for creating the Process Context for the specific process in Kernel space (if the context does not exist) and set the _WNF_PROCESS_CONTEXT->NotificationEvent.

Then a User Subscription and a Name Subscription are created, and the User Subscription is added to the associated Name Subscription.

The RtlpCreateWnfNameSubscription wil first try to find an already existing Name Subscription for the given StateName. If no pre-existing Name Subscription has been found in the Subscription Table, it creates one and adds it to the Subscription Table.

At this point, the process is subscribed to the specified WNF Event and ready to handle future WNF Notifications!

Publishing Data

In order to update the StateData for an already existing StateName, we can use NtUpdateWnfStateData.

extern "C"
NTSTATUS
NTAPI
NtUpdateWnfStateData(
    _In_ PCWNF_STATE_NAME StateName, // ULONG64
    _In_reads_bytes_opt_(Length) const VOID* Buffer, // PVOID 
    _In_opt_ ULONG Length, // ULONG64
    _In_opt_ PCWNF_TYPE_ID TypeId, // ULONG64
    _In_opt_ const VOID* ExplicitScope, // PVOID
    _In_ WNF_CHANGE_STAMP MatchingChangeStamp, // ULONG
    _In_ LOGICAL CheckStamp // ULONG
);

Note that you will only be able to update StateData for those StateNames you have write privileges for.

Also note that the BufferLenght must be smaller than the StateName’s max size (specified upon creation), which always will be less than 4096 bytes.

In order to specify an ExplicitScope, you need to pass the Scope identifier as a parameter.

If CheckStamp is true, then the StateData will only be updated if the CurrentChangeStamp matches the one specified as the sixth parameter.

To avoid making this post excesively long, I will not paste here the Kernel RE’d version of NtUpdateWnfStateData (ExpNtUpdateWnfStateData). However, this is what it does under the hood:

  1. Gets the StateName struct from the passed ULONG.
  2. Checks if the specified Scope and the StateName Scope are correct and the process has access.
  3. Checks if the process has enough privileges to update the StateData.
  4. Looks for the Name Instance or creates one if it was not already created.
  5. Writes the StateData. If the Name Instance has a Permanent Data Store (Registry Key), it will also update the associated Registry Key with the new StateData.

Name Instances are usually created upon StateName’s registration. But the fact that Name Instances can be created when trying to write a StateData indicates that we may be able to publish data over not registered StateNames. So we don’t need a registered StateName in order to publish data!

When writing the StateData, if the Name Instance has a Permanent Data Store (which will be a Registry Key, as shown in the Name Instances section), it will also save the StateData inside the associated Permanent Data Store.

After writing the StateData, all subscribers will be notified.

This method will iterate over every _WNF_SUBSCRIPTION pointed by the Name Instance, triggering the NotificationEvents of their respective processes.

It is also possible to clear the StateData by calling NtDeleteWnfStateData, which will set the NameInstance->StateData to 0.

Querying Data

It is possible to read the StateData even if the process is not subscribed to the StateName. We can use NtQueryWnfStateData for this purpose.

extern "C"
NTSTATUS
NTAPI
NtQueryWnfStateData(
    _In_ PWNF_STATE_NAME StateName, // ULONG64
    _In_opt_ PWNF_TYPE_ID TypeId, // ULONG64
    _In_opt_ const VOID * ExplicitScope, // PVOID
    _Out_ PWNF_CHANGE_STAMP ChangeStamp, // PVOID
    _Out_writes_bytes_to_opt_(*BufferSize, *BufferSize) PVOID Buffer, // PVOID
    _Inout_ PULONG BufferSize // PULONG
);

Using this API is pretty straightforward. Basically is enough to specify the StateName and allocate enough space for the buffer.

The next code snippet shows how can we update an StateName and then query the StateData. Note that in case NtQueryWnfStateData fails with BUFFER_TOO_SMALL, we should allocate a new buffer with the specified size (returned inside bufferSize) and then call again NtQueryWnfStateData to get the StateData.

int main()
{
    NTSTATUS status;
    WnfName wnfName;
    WNF_CHANGE_STAMP changeStamp;
    ULONG bufferSize = 1;
    WNF_STATE_NAME StateName = wnfName.name2state("WNF_EDGE_LAST_NAVIGATED_HOST");

    const char* data = "Hi There!\0";
    ULONG dataSize = (ULONG)strlen(data) + 1;
    status = NtUpdateWnfStateData(&StateName, data, dataSize, nullptr, nullptr, 0, FALSE);
    if (status != 0)
    {
        printf("[!] Error publishing data.");
    }

    // Alloc the buffer to receive data
    PVOID buffer = (PVOID)malloc(bufferSize);
    if (!buffer) {
        return 1;
    }
    memset(buffer, 0, bufferSize);

    // Check the size. If buffer was too small, realloc more size and try again
    status = NtQueryWnfStateData(&StateName, nullptr, nullptr, &changeStamp, buffer, &bufferSize);
    if (status == 0xC0000023) // BUFFER_TOO_SMALL
    {
        PVOID oldbuffer = buffer;
        buffer = realloc(oldbuffer, bufferSize);
        if (!buffer) {
            free(oldbuffer);
            return 1;
        }
        memset(buffer, 0x00, bufferSize); 

        status = NtQueryWnfStateData(&StateName, nullptr, nullptr, &changeStamp, buffer, &bufferSize);
    }
    
    
    if (status != 0) 
    {
        printf("[!] Error querying data: %X.\n", status);
    }
    else if (bufferSize == 0x0)
    {
        printf("[+] Empty StateData.");
    }
    else
    {
        printf("[+] Data: %s\n", (char*)buffer);
    }

    free(buffer);
}

Internally, this method works in a similar way to NtUpdateWnfStateData. It will try to resolve the Scope Instance and get the Name Instance for the specified StateName. If the Name Instance exists and the process has enough privileges, it will read the StateData. However, if the StateName does not exist, it is created. Again, we can query data from a StateName that is not registered.

Creating New StateNames

We can use NtCreateWnfStateName for this purpose and NtDeleteWnfStateName for deleting it.

extern "C"
NTSTATUS
NTAPI
NtCreateWnfStateName(
    _Out_ PWNF_STATE_NAME StateName, // ULONG64, out
    _In_ WNF_STATE_NAME_LIFETIME NameLifetime, // UINT
    _In_ WNF_DATA_SCOPE DataScope, // UINT
    _In_ BOOLEAN PersistData, // BOOLEAN
    _In_opt_ PCWNF_TYPE_ID TypeId, // ULONG64
    _In_ ULONG MaximumStateSize, // ULONG
    _In_ PSECURITY_DESCRIPTOR SecurityDescriptor // PSECURITY_DESCRIPTOR
);

extern "C"
NTSTATUS
NTAPI
NtDeleteWnfStateName(
    _In_ PCWNF_STATE_NAME StateName
);

This API will resolve the Scope Instance and create a new Name Instance. It will also create the StateName, which will be returned in the first argument.

If the StateName is Permanent or Persistent, it will also be added to the associated Registry Key.

Conclusion

This post aims to cover all the information I have about how WNF works. This is intended to be a reference and not a thorough documentation, so probably there are a lot of missing stuff here. I will try to update this post with new information as soon as I discover new things.

In future posts I will try to focus on practical scenarios where WNF can be useful. Among other things, I plan on writing about IPC, Process Injection and Data Persistence.

Hope you enjoyed it! ❤️