# Windows Registry Analysis – Today’s Episode: Tasks

When it comes to persistence of common off-the-shelf malware, the most commonly observed persistence mechanisms are run keys, services, and scheduled tasks. For either of these, Windows or even the malware itself creates a set of registry keys to register the persistence mechanism with the operating system. Out of these mechanisms, this blog post specifically focuses on scheduled tasks. These are particularly interesting since they allow for much more versatile launch conditions and actions compared to services or run keys.All tasks currently registered to a Windows machine are represented by a set of registry keys and values in the HKLM\Software\Microsoft\Windows NT\CurrentVersion\Schedule\TaskCache tree. To the keen eye, most of the registry keys and their values are recognizable as they have descriptive names or contain strings. However, there are some values which seemingly contain binary data – and those are what this blog post is (mainly) about.

But before we dive into the depths of the Windows registry and the task scheduler, let me provide an important disclaimer: all this research was conducted using Windows 10 1909 with the occasional claim being verified on a Windows 7 SP1. Yet, there is certainly information presented in this blog post which is not valid for operating systems older than Windows 10. From my experience during this research, these differences are regularly just elements in structures which were not yet present in, for example, Windows 7. Vice versa, there are elements in the structures which are no longer used in current versions of the Windows operating system. Although these fields might not be used nowadays, they are still present in Windows 10. I assume this is to ensure backwards compatibility so that after, for example, an upgrade of the operating system, the tasks from the older version still work as expected with the newer one.

To make parsing the raw registry data easier, I created a set of struct definitions using kaitai struct. I published the kaitai definitions and some tooling based on these definitions on Github. For more information regarding how to use either of it, please refer to the README in the repository: https://github.com/GDATAAdvancedAnalytics/winreg-tasks

With all that being said, let’s get into it.

## The Happy Path – Creating a new Task and What Happens in the Registry

Assume we just created a new Task called “Simple Task” which starts calc.exe whenever a user logs on. The task scheduler then creates a set of registry keys which hold the information entered in the task creation wizard. These registry keys are split into roughly two groups which reference each other. The first picture shows the task in the Tree subkey, the second picture shows the values found in the respective Tasks subkey (please note my exceptional GIMP skills!). The arrows denote the references from one group to the other. I discuss the different subkeys and their values in the next section.

## Structural Overview

The TaskCache key has several subkeys which contain and organize all tasks registered in the system. All elements in the Tasks subkey reference a key in the Tree subkey and vice versa. Boot, Logon, Maintenance, and Plain tasks only have an ID value which references a key in the Tasks subkey.

The subkeys in the Tree key usually contain up to three values but only hold very limited information about a given task. If the subkey stands for a directory in the task scheduler snap-in and does not represent a task, there only is the SD value present which denotes the access rights to this specific task folder (if there is no SD value, the folder or task is hidden from the list of tasks for the user querying their task list). If a subkey references a task, there is at least the Id value set in addition to the SD value. Usually, the subkey then also contains an Index value but the presence of this specific value does not seem to be mandatory.

Each subkey in Tasks has several Values which define the task. Usually, only a subset of the values provided in the table below are set for a task. This is because many of these values are optional and are not saved in the registry if empty (e.g. Author, Data, Description, Documentation, Source). In fact, Actions and Triggers seem to be the only required values – for the MMC snap-in to show the task at least. But while the snap-in is much more restrictive with regards to how the state of the registry is, the task scheduler service is much less so. Thus, even a task without any Actions and Triggers might still be considered valid by the task scheduler service albeit the task not being of any use.

## Dynamic Information – The “When it Happened”

The DynamicInfo Value contains three timestamps denoting when the task was created, its last execution time, and the last time it finished successfully. The structure also holds any error code which occurred during the latest execution of the task. Prior to (at least) Windows 7, the structure also contained the current state of the task. But this value seems to be no longer used in this specific place but needs to be kept for compatibility reasons, presumably.A pseudo-C structure for the DynamicInfo Value could look like this:

struct DynamicInfo {
DWORD magic; // currently 0x3
FILETIME ftCreate;
FILETIME ftLastRun;
DWORD dwTaskState;
DWORD dwLastErrorCode;
FILETIME ftLastSuccessfulRun; // this field may not be present on older Windows versions (e.g. Windows 7)
};

Given our introductory example from above, the DynamicInfo Value may contain this byte sequence:

03 00 00 00
e9 79 2d f1 31 1c d8 01 # Mon 7 February 2022 14:49:43 UTC
5b 8b 6b 73 34 1c d8 01 # Mon 7 February 2022 16:19:59 UTC
00 00 00 00
00 00 00 00             # ERROR_SUCCESS
e4 70 d5 67 34 1c d8 01 # Mon 7 February 2022 15:07:21 UTC

If we changed the action of the task to execute a non-existent file, the data might look like this (note the non-zero error code):

03 00 00 00
e9 79 2d f1 31 1c d8 01 # Mon 7 February 2022 14:49:43 UTC
62 76 13 3b 33 1c d8 01 # Mon 7 February 2022 16:20:39 UTC
00 00 00 00
02 00 07 80             # 0x80070002 -> ERROR_FILE_NOT_FOUND
4c 30 75 3b 33 1c d8 01 # Mon 7 February 2022 16:20:39 UTC

## Actions – The “What Should Happen”

Whenever a Task is triggered by the scheduler, it may execute a set of actions. PowerShell does not allow to create more than 32 actions for a task, however, technically there is almost no limit to how many actions the scheduling service can handle. The only limitation seems to be the amount of elements an STL container can hold.Once again referring to the introductory example, the Actions value may contain a byte sequence similar to the following one. Since we only set the command to calc and did not add any arguments or passed a working directory, the structure is relatively small.

03 00                                            # version
0c 00 00 00 41 00 75 00 74 00 68 00 6f 00 72 00  # context ("Author")
66 66                                            # magic 0x6666 (-> execution action)
00 00 00 00                                      # id
08 00 00 00 63 00 61 00 6c 00 63 00              # command ("calc")
00 00 00 00                                      # arguments
00 00 00 00                                      # working directory
00 00                                            # flags

If one decides to add more details to the actions, the structure can grow in size rapidly. The next example shows the byte sequence for the Actions value when the action also passes arguments to the command and changes the working directory:

03 00                                            # version
0c 00 00 00 41 00 75 00 74 00 68 00 6f 00 72 00  # context ("Author")
66 66                                            # magic 0x6666 (-> execution action)
00 00 00 00                                      # id
08 00 00 00 63 00 61 00 6c 00 63 00              # command ("calc")
2c 00 00 00 61 00 72 00 67 00 31 00 20 00        # arguments ("arg1 arg2 verylongarg3")
61 00 72 00 67 00 32 00 20 00 76 00 65 00     #
72 00 79 00 6c 00 6f 00 6e 00 67 00 61 00     #
72 00 67 00 33 00                             #
56 00 00 00 43 00 3a 00 5c 00 74 00 68 00        # working directory ("C:\this\is\a\very\long\path\to\a\directory\")
69 00 73 00 5c 00 69 00 73 00 5c 00 61 00     #
5c 00 76 00 65 00 72 00 79 00 5c 00 6c 00     #
6f 00 6e 00 67 00 5c 00 70 00 61 00 74 00     #
68 00 5c 00 74 00 6f 00 5c 00 61 00 5c 00     #
64 00 69 00 72 00 65 00 63 00 74 00 6f 00     #
72 00 79 00 5c 00                             #
00 00                                            # flags

### The Actions Structure

A pseudo-C representation of the byte sequences from above cannot be given as easily as for the DynamicInfo . This is mainly because there is not only the execution action which we just looked at, but there is also email actions, COM handlers, and message box actions. We define these actions throughout the course of this section but for now we just assume they exist and “forward declare” them. The Actions structure can then be laid out like this:

struct Action;
struct ComHandlerAction : Action;
struct EmailAction : Action;
struct ExecutionAction : Action;
struct MessageBoxAction : Action;

struct Actions {
WORD version; // 0x16 on Win10 1909
BSTR context; // the id of the principal which is used to run the actions; must match the principal_id of the JobBucket (see below)
Action actions[]; // repeated until EOF
};

Technically, the Actions structure holds two properties: magic and id. But since the magic differs between all actions and the id is specific to each individual action, I include these two fields into the definitions of the sub structures and not into the generic Action structure. Either way, the magic denotes which type of action comes next in the data stream and the id may be used to assign to it an easily recognizable name. It seems that setting an id for an action is only supported when using the COM interface but not when using any other front-end tooling (Powershell, Task Scheduler Snap-In).

### ComHandler Action

The ComHandler action is the smallest one to find in the registry. Besides the common magic and id , it only contains a CLSID and a string for (optional) additional data:

struct ComHandlerAction : Action {
WORD magic = 0x7777;
BSTR id;
CLSID classId;
BSTR data;
}; 

Whenever one of these actions is triggered, the task scheduler service spawns a new taskhostw.exe process which then loads and executes the COM class configured by the classId property of the action. The COM class must implement the ITaskHandler interface and its Start method is passed the value from the data property of the action.When parsing the CLSID from the registry one must pay special attention to the byte ordering: since the data in this structure is memcpy’d into a buffer, the order order of bytes for data1 , data2 , and data3 is inverted. See the following listing for an example:

03 00 # version
14 00 00 00 4c 00 6f 00 63 00 61 00 6c 00 41 00 64 00 6d 00 69 00 6e 00 # context ("LocalAdmin")
77 77 # magic
00 00 00 00 # id
c2 d0 d1 89 cf a3 0c 49 ab e3 b8 6c de 34 b0 47 # clsid {89d1d0c2-a3cf-490c-abe3-b86cde34b047} (ReAgentTaskHandler)
16 00 00 00 56 00 65 00 72 00 69 00 66 00 79 00 57 00 69 00 6e 00 52 00 45 00 # data ("VerifyWinRE")

### Email Action

Although being obsolete and discontinued, tasks technically can still have Email actions as defined by the IEmailAction COM interface. However, the task scheduler is only able to parse the respective data from the registry but does not execute Email tasks anymore.

struct EmailAction : Action {
WORD magic = 0x8888;
BSTR id;
BSTR from;
BSTR to;
BSTR cc;
BSTR bcc;
BSTR replyTo;
BSTR server;
BSTR subject;
BSTR body;
DWORD numAttachments;
BSTR attachmentFilenames[numAttachments];
DWORD numHeaders;
Pair<BSTR, BSTR> headers[numHeaders]; // "BSTR headerName; BSTR headerValue;" x numHeaders
}

### Execution Action

The execution action is the only action which can be created using the task scheduler MMC snap-in (technically, you can also create email and message box actions – you are just not allowed to save the task then). An execution task has three customizable properties (not counting the id) which are defined in the IExecAction COM interface.

struct ExecutionAction : Action {
WORD magic = 0x6666;
BSTR id;
BSTR command;
BSTR arguments;
BSTR workingDirectory;
WORD flags; // only present if Actions.version >= 3
};

Example:

03 00 # version
0c 00 00 00 41 00 75 00 74 00 68 00 6f 00 72 00 # context ("Author")
66 66 # magic
00 00 00 00 # id
46 00 00 00 25 00 73 00 79 00 73 00 74 00 65 00 # command ("%systemroot%\system32\usoclient.exe")
6d 00 72 00 6f 00 6f 00 74 00 25 00 5c 00 73 00
79 00 73 00 74 00 65 00 6d 00 33 00 32 00 5c 00
75 00 73 00 6f 00 63 00 6c 00 69 00 65 00 6e 00
74 00 2e 00 65 00 78 00 65 00
18 00 00 00 53 00 74 00 61 00 72 00 74 00 49 00 # params ("StartInstall")
6e 00 73 00 74 00 61 00 6c 00 6c
00 00 00 00 00 # working directory
00 00 # flags

### Message Box Action

Similarly to Email actions, the MessageBox actions (or ShowMessage action as per Microsoft terms) have been discontinued and can no longer be used with the task scheduler. The structure of the data is simple, though, as a user only needed to define the caption and the content of the message box to show upon task activation.

struct MessageboxAction : Action {
WORD magic = 0x9999;
BSTR id;
BSTR caption;
BSTR content;
}

## Triggers – The “When Should it Happen”

The second, but not less important, building block of Windows tasks is their triggers. Simply put, triggers define when a given task shall be executed. Windows offers a range of different triggers (e.g. calendar-based triggers, boot triggers, logon triggers) which additionally have a variety of customization options (e.g. execution delay, repetition timings). All triggers share the same set of options but can be configured individually. I describe the top-level structure first and then descend into the different sub structures.The most significant difference between Triggers and Actions is that the data in the Triggers structure is aligned to 8-byte boundaries whereas the data in the Actions is not aligned at all. This makes parsing the Triggers more tedious since one must pay special attention to data alignments and cannot just read a number of bytes and interpret them. Unfortunately, this also makes the structure definitions look bloated. To counteract that, I define an enhanced set of types which have an ALIGNED_ prefix which hints that the encapsulated data is padded a multiple of 8 bytes. The following example applies to WORD, DWORD, and any other type analogously where necessary:

struct ALIGNED_BYTE {
BYTE value;
BYTE padding[7];
}

### The Triggers Structure

The triggers structure is defined by only three major members: the Header, the JobBucket, and the collection of Triggers.

struct Triggers {
Header header;
JobBucket bucket;
Trigger triggers[]; // repeated until eof
}

### The Header

The header only denotes the version of the struct and the time and date when the task shall be activated at and deactivated at, respectively. On Windows 10 1909, the struct version is 0x17; Windows 7 used 0x15.

struct Header {
ALIGNED_BYTE version;
TSTIME startBoundary; // the earliest startBoundary of all triggers
TSTIME endBoundary; // the latest endBoundary of all triggers
}

### The JobBucket

The JobBucket holds general information about the task, its triggers, and all of the options and settings which can be set for a task.

struct JobBucket {
// see Github ("job_bucket_flags") for a list of identified values
ALIGNED_DWORD flags;

// the crc32 checksum of the task XML
ALIGNED_DWORD crc32;

// the id of the principal which is used to execute the task; only if header.version >= 0x16
ALIGNED_BSTR principal_id;

// the DisplayName of the task principal; only if header.version >= 0x17
ALIGNED_BSTR display_name;

// the task principal
UserInfo user_info;

// if set, this structure contains additional settings which are not mandatory when creating a task
OptionalSettings optional_settings;
}

The task scheduler allows running tasks as a different user than the one who created the task initially. The UserInfo struct contains the information which are necessary to impersonate the task principal.

struct UserInfo {
// if non-zero, the rest of the struct is omitted in the registry
ALIGNED_BYTE skip_user;

// only if skip_user == 0
ALIGNED_BYTE skip_sid;

// any value of the SID_NAME_USE enum; only if skip_user == 0 and skip_sid == 0
ALIGNED_DWORD sid_type;

// SID in binary form; only if skip_user == 0 and skip_sid == 0
ALIGNED_BUFFER sid;

// only if skip_user == 0
ALIGNED_BSTR username;
}

The OptionalSettings contain the preferences and settings from the Conditions and Settings tabs in the Task Scheduler snap-in as well as additional settings which can (only) be set using the Task Scheduler COM interface (ITaskSettings, ITaskSettings2, ITaskSettings3).

struct OptionalSettings {
ALIGNED_DWORD len; // if len == 0, the rest of the structure is omitted in the registry
DWORD idle_duration_seconds;
DWORD idle_wait_timeout_seconds;
DWORD execution_time_limit_seconds;
DWORD delete_expired_task_after;
DWORD priority;
DWORD restart_on_failure_delay;
DWORD restart_on_failure_retries;
GUID network_id;
BYTE padding0[4]; // probably there because the previous struct members are part of another struct which is inlined here
BYTE privileges; // only if len == 0x38 or len == 0x58
TSTIMEPERIOD periodicity; // only if len == 0x58
TSTIMEPERIOD deadline; // only if len == 0x58
BYTE exclusive; // only if len == 0x58
BYTE padding1[3]; // only if len == 0x58
}

Several of these settings have corresponding input fields in the task scheduler snap-in. To make these more easily recognizable, I copied over the labels to their respective setting in the struct where applicable:

• idle_duration_seconds: “start the task only if the computer is idle for” setting converted to seconds
• idle_wait_timeout_seconds: “wait for idle for” converted to seconds
• execution_time_limit_seconds: “stop the task if it’s running longer than” converted to seconds
• delete_expired_task_after: “if the task is not scheduled to run again, delete it after” converted to seconds
• priority: the process priority value the task scheduler assigns to the task process
• restart_on_failure_delay: “if the task fails, restart every” converted to seconds
• restart_on_failure_retries: “attempt to restart up to”
• network_id: “start only if the following network connection is available”
• privileges: a bitmap of Se* permissions (e.g., SeDebugPrivilege; see Github repository for full list) to grant to the task process when runningperiodicity: the amount of time a task needs when executed during automatic system maintenance (only applies to maintenance tasks)
• deadline: defines the amount of time which is allowed to pass before the task is executed during emergency maintenance if it failed to complete during normal maintenance (only applies to maintenance tasks)
• exclusive: defines whether the task shall be run as an exclusive task with no other maintenance task running at the same time (only applies to maintenance tasks)

### The Triggers

The list of trigger structures defines the triggers of a given task. There is (almost) no technical limit to the amount of triggers a task is allowed to have. Yet, there commonly are only 1 to 3 triggers with the occasional outlier in my testing system having up to 6 triggers.

Different to the Actions described above, the triggers only share the common field magic. However, I again include the magic into the different trigger definitions so that is is more easily recognizable which magic a given trigger has (similarly to how I have done it with the Actions above).

All of the triggers below contain a GenericData field (except for the TimeTrigger which uses a JobSchedule). I describe the structure below the trigger definitions.

The examples given for the different triggers may contain garbage or suspiciously-looking data in several fields. This mainly has two reasons. On the one hand, the task scheduler fills the buffer for the registry data with the character H before serializing any data. This is where all the 0x48 come from. On the other hand, the data structures being memcpy‘d into the buffer are usually not initialized. This has the side effect that there may be content from the stack (or heap, depending on where the source data structure resides) spoiled into the allocated buffer and eventually written into the registry. I have observed heap and stack pointers where only the least significant byte was overridden by a field of the structure, partial strings, and also “random” data which I could not identify.

#### WnfStateChangeTrigger

This trigger listens for notifications in the Windows Notification Framework (WNF). It appears that this trigger is intended to only be used internally in Windows, because the Microsoft documentation only provides the non-descriptive trigger type TASK_TRIGGER_CUSTOM_TRIGGER_01 for this trigger type. However, if one is well-versed with COM and how to work with “unknown” interfaces, it should be possible to create even these triggers.

struct WnfStateChangeTrigger {
ALIGNED_DWORD magic = 0x6666;
GenericData genericData;
BYTE state_name[8];
ALIGNED_DWORD cbData;
BYTE data[cbData];
}

Example:

17 00 00 00 00 00 00 00                         # header.version (0x17)
00 7c 10 22 98 7c 10 22 00 00 00 00 00 00 00 00 # header.start_boundary (localized: no, filetime: 0)
00 7c 10 22 98 7c 10 22 ff ff ff ff ff ff ff ff # header.end_boundary (localized: no, filetime: 0xffffffffffffffff)
00 90 c0 42 48 48 48 48                         # job_bucket.flags (0x42c09000)
27 82 bb 7f 48 48 48 48                         # job_bucket.crc32 (0x7fbb8227)
0c 00 00 00 48 48 48 48 55 00 73 00 65 00 72 00 73 00 00 00 48 48 48 48 # job_bucket.principal_id ("Users")
00 00 00 00 48 48 48 48                         # job_bucket.display_name ("")
00 48 48 48 48 48 48 48                         # job_bucket.user_info.skip_user (0x00 -> user struct is present)
00 48 48 48 48 48 48 48                         # job_bucket.user_info.skip_sid (0x00 -> sid is present)
05 00 00 00 48 48 48 48                         # job_bucket.user_info.sid_type (0x05 -> SidTypeWellKnownGroup)
0c 00 00 00 48 48 48 48 01 01 00 00 00 00 00 05 04 00 00 00 48 48 48 48 # SID (S-1-5-4 "INTERACTIVE")
00 00 00 00 48 48 48 48                         # job_bucket.user_info.username ("")
2c 00 00 00 48 48 48 48                         # job_bucket.optional_settings.len (0x2c)
00 00 00 00                                     # job_bucket.optional_settings.idle_duration_seconds (0)
ff ff ff ff                                     # job_bucket.optional_settings.idle_wait_timeout_seconds (-1)
58 02 00 00                                     # job_bucket.optional_settings.execution_time_limit_seconds (600)
ff ff ff ff                                     # job_bucket.optional_settings.delete_expired_task_after (-1)
06 00 00 00                                     # job_bucket.optional_settings.priority (6)
00 00 00 00                                     # job_bucket.optional_settings.restart_on_failure_retries (0)
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # job_bucket.optional_settings.network_id
00 00 00 00 48 48 48 48                         # job_bucket.optional_settings.padding0
66 66 00 00 00 00 00 00                         # triggers[0].magic (0x6666)
00 7c 10 22 98 7c 10 22 00 00 00 00 00 00 00 00 # triggers[0].generic_data.start_boundary (localized: no, filetime: 0)
00 7c 10 22 98 7c 10 22 ff ff ff ff ff ff ff ff # triggers[0].generic_data.end_boundary (localized: no, filetime: 0xffffffffffffffff)
00 00 00 00                                     # triggers[0].generic_data.delay_seconds (0)
ff ff ff ff                                     # triggers[0].generic_data.timeout_seconds (-1)
00 00 00 00                                     # triggers[0].generic_data.repetition_interval_seconds (0)
00 00 00 00                                     # triggers[0].generic_data.repetition_duration_seconds (0)
00 00 00 00                                     # triggers[0].generic_data.repetition_duration_seconds_2 (0)
00                                              # triggers[0].generic_data.stop_at_duration_end (false)
e3 87 00                                        # triggers[0].generic_data.padding
01 7e e8 fa cd 31 1c be                         # triggers[0].generic_data.enabled (true)
6f 8a 99 8f 84 0b 3e 42                         # triggers[0].generic_data.unknown
00 00 00 00                                     # triggers[0].generic_data.trigger_id ("")
48 48 48 48                                     # triggers[0].generic_data.pad_to_block
75 78 bc a3 3a 07 80 08                         # triggers[0].state_name ("7578bca33a078008", WNF_WIFI_TASK_TRIGGER)
00 00 00 00 00 00 00 00                         # triggers[0].cbData (0)

#### SessionChangeTrigger

Triggers a task whenever the session state of the given user changes (see JobBucket for the definition of the UserInfo structure). Session state changes can be console connect and disconnect, remote connect and disconnect, and session lock and unlock. The specific values for the session states can be found in the Github repository (“session_state” enum).

struct SessionChangeTrigger {
ALIGNED_DWORD magic = 0x7777;
GenericData genericData;
ALIGNED_DWORD dwStateChange;
UserInfo user;
}


Example (only the trigger structure, full example at WnfStateChangeTrigger):

77 77 00 00 00 00 00 00                         # magic (0x7777)
00 67 10 22 80 67 10 22 00 00 00 00 00 00 00 00 # start_boundary (localized: no, filetime: 0)
00 67 10 22 80 67 10 22 ff ff ff ff ff ff ff ff # end_boundary (localized: no, filetime: 0xffffffffffffffff)
58 02 00 00                                     # delay_seconds (600)
ff ff ff ff                                     # timeout_seconds (0xffffffff)
00 00 00 00                                     # repetition_interval_seconds (0)
00 00 00 00                                     # repetition_duration_seconds (0)
00 00 00 00                                     # repetition_duration_seconds_2 (0)
00                                              # stop_at_duration_end (false)
e3 87 00                                        # padding
00 00 49 00 6e 00 73 00                         # enabled (false)
74 00 61 00 6c 00 6c 00                         # unknown
34 00 00 00 4c 00 6f 00 63 00 61 00 6c 00 43 00 # trigger_id ("LocalConsoleConnectTrigger")
6f 00 6e 00 73 00 6f 00 6c 00 65 00 43 00 6f 00
6e 00 6e 00 65 00 63 00 74 00 54 00 72 00 69 00
67 00 67 00 65 00 72 00
01 00 00 00                                     # state_change ("ConsoleConnect")
65 00 00 00                                     # padding
01 48 48 48 48 48 48 48                         # user_info.skip_user (true -> no user struct)


#### RegistrationTrigger

Triggers the task actions when the task registered or updated.

struct RegistrationTrigger {
ALIGNED_DWORD magic = 0x8888;
GenericData genericData;
}


Example (only the trigger structure, full example at WnfStateChangeTrigger):

88 88 00 00 00 00 00 00                         # magic (0x8888)
00 59 10 22 70 59 10 22 00 00 00 00 00 00 00 00 # start_boundary (localized: no, filetime: 0)
00 59 10 22 70 59 10 22 ff ff ff ff ff ff ff ff # end_boundary (localized: no, filetime: 0xffffffffffffffff)
00 00 00 00                                     # delay_seconds (0)
ff ff ff ff                                     # timeout_seconds (0xffffffff)
00 00 00 00                                     # repetition_interval_seconds (0)
00 00 00 00                                     # repetition_duration_seconds (0)
00 00 00 00                                     # repetition_duration_seconds_2 (0)
00                                              # stop_at_duration_end (false)
e3 87 00                                        # padding
01 00 00 00 00 00 00 00                         # enabled (true)
4c 4d 45 4d 48 00 00 00                         # unknown
00 00 00 00                                     # trigger_id
48 48 48 48                                     # block padding


#### LogonTrigger

Triggers a task whenever the given user logs on (see JobBucket for the definition of the UserInfo structure).

struct LogonTrigger {
ALIGNED_DWORD magic = 0xAAAA;
GenericData genericData;
UserInfo user;
}


Example (only the trigger structure, full example at WnfStateChangeTrigger):

aa aa 00 00 00 00 00 00                         # magic (0xaaaa)
00 59 10 22 70 59 10 22 00 00 00 00 00 00 00 00 # start_boundary (localized: no, filetime: 0)
00 59 10 22 70 59 10 22 ff ff ff ff ff ff ff ff # end_boundary (localized: no, filetime: 0xffffffffffffffff)
00 00 00 00                                     # delay_seconds (0)
ff ff ff ff                                     # timeout_seconds (0xffffffff)
80 70 00 00                                     # repetition_interval_seconds (28800 "PT8H")
00 00 00 00                                     # repetition_duration_seconds (0)
00 00 00 00                                     # repetition_duration_seconds_2 (0)
00                                              # stop_at_duration_end (false)
e3 87 00                                        # padding
01 a9 a7 1c 94 a9 a7 1c                         # enabled (true)
00 00 00 00 00 00 00 00                         # unknown
00 00 00 00                                     # trigger_id
48 48 48 48                                     # block padding
01 48 48 48 48 48 48 48                         # user_info.skip_user (true -> no user struct)


#### EventTrigger

Triggers a task based on events that appeared in the Windows logs. The subscription field contains a list of XPATH queries which create the filters the task scheduler uses to check whether the conditions for the EventTrigger are fulfilled.

struct EventTrigger {
ALIGNED_DWORD magic = 0xCCCC;
GenericData genericData;
ALIGNED_BSTR_EXPANDSIZE subscription;
DWORD unknown0;
DWORD unknown1;
ALIGNED_BSTR_EXPANDSIZE unknown2;
ALIGNED_DWORD len_value_queries;
Pair<ALIGNED_BSTR_EXPANDSIZE, ALIGNED_BSTR_EXPANDSIZE> value_queries; // "ALIGNED_BSTR_EXPANDSIZE name; ALIGNED_BSTR_EXPANDSIZE query;" x len_value_queries
}

Example (only the trigger structure, full example at WnfStateChangeTrigger):

cc cc 00 00 00 00 00 00                         # magic (0xcccc)
00 59 10 22 70 59 10 22 00 00 00 00 00 00 00 00 # start_boundary (localized: no, filetime: 0)
00 59 10 22 70 59 10 22 ff ff ff ff ff ff ff ff # end_boundary (localized: no, filetime: 0xffffffffffffffff)
dc 05 00 00                                     # delay_seconds (1500 "PT25M")
08 07 00 00                                     # timeout_seconds (1800 "PT30M")
10 0e 00 00                                     # repetition_interval_seconds (3600 "PT1H")
40 38 00 00                                     # repetition_duration_seconds (14400 "PT4H")
40 38 00 00                                     # repetition_duration_seconds_2 (14400 "PT4H")
00                                              # stop_at_duration_end (false)
e3 87 00                                        # padding
01 00 00 00 00 00 00 00                         # enabled (true)
0c 00 00 00 00 00 00 00                         # unknown
00 00 00 00                                     # trigger_id
48 48 48 48                                     # block padding
05 01 00 00 00 00 00 00 3c 00 51 00 75 00 65 00 # subscription: <Select Path="Microsoft-Windows-User Device Registration/Admin">*[System[Provider[@Name='Microsoft-Windows-User Device Registration'] and EventID=300]]</Select></Query></QueryList>
72 00 79 00 4c 00 69 00 73 00 74 00 3e 00 3c 00
51 00 75 00 65 00 72 00 79 00 20 00 49 00 64 00
3d 00 22 00 30 00 22 00 20 00 50 00 61 00 74 00
68 00 3d 00 22 00 4d 00 69 00 63 00 72 00 6f 00
73 00 6f 00 66 00 74 00 2d 00 57 00 69 00 6e 00
64 00 6f 00 77 00 73 00 2d 00 55 00 73 00 65 00
72 00 20 00 44 00 65 00 76 00 69 00 63 00 65 00
20 00 52 00 65 00 67 00 69 00 73 00 74 00 72 00
61 00 74 00 69 00 6f 00 6e 00 2f 00 41 00 64 00
6d 00 69 00 6e 00 22 00 3e 00 3c 00 53 00 65 00
6c 00 65 00 63 00 74 00 20 00 50 00 61 00 74 00
68 00 3d 00 22 00 4d 00 69 00 63 00 72 00 6f 00
73 00 6f 00 66 00 74 00 2d 00 57 00 69 00 6e 00
64 00 6f 00 77 00 73 00 2d 00 55 00 73 00 65 00
72 00 20 00 44 00 65 00 76 00 69 00 63 00 65 00
20 00 52 00 65 00 67 00 69 00 73 00 74 00 72 00
61 00 74 00 69 00 6f 00 6e 00 2f 00 41 00 64 00
6d 00 69 00 6e 00 22 00 3e 00 2a 00 5b 00 53 00
79 00 73 00 74 00 65 00 6d 00 5b 00 50 00 72 00
6f 00 76 00 69 00 64 00 65 00 72 00 5b 00 40 00
4e 00 61 00 6d 00 65 00 3d 00 27 00 4d 00 69 00
63 00 72 00 6f 00 73 00 6f 00 66 00 74 00 2d 00
57 00 69 00 6e 00 64 00 6f 00 77 00 73 00 2d 00
55 00 73 00 65 00 72 00 20 00 44 00 65 00 76 00
69 00 63 00 65 00 20 00 52 00 65 00 67 00 69 00
73 00 74 00 72 00 61 00 74 00 69 00 6f 00 6e 00
27 00 5d 00 20 00 61 00 6e 00 64 00 20 00 45 00
76 00 65 00 6e 00 74 00 49 00 44 00 3d 00 33 00
30 00 30 00 5d 00 5d 00 3c 00 2f 00 53 00 65 00
6c 00 65 00 63 00 74 00 3e 00 3c 00 2f 00 51 00
75 00 65 00 72 00 79 00 3e 00 3c 00 2f 00 51 00
75 00 65 00 72 00 79 00 4c 00 69 00 73 00 74 00
3e 00 00 00 48 48 48 48
00 00 00 00                                     # unknown0 (0)
00 00 00 00                                     # unknown1 (0)
00 00 00 00 00 00 00 00                         # unknown2 ("")
00 00 00 00 00 00 00 00                         # len_value_queries (0)

#### TimeTrigger

Unlike all other triggers, a TimeTrigger does not have a GenericData field. This is, because time-based triggers allow for more fine-grained options as to when a task shall be run. The GenericData does not have the required fields to hold all properties of these options and thus the JobSchedule replaces it. The only significant difference compared to other tasks is that a JobSchedule does not contain the trigger_id field so the TimeTrigger structure holds one instead. For all other structures, the trigger_id is part of the GenericData field (see below).

struct TimeTrigger {
ALIGNED_DWORD magic = 0xDDDD;
JobSchedule job_schedule;
BSTR trigger_id; // only if header.version >= 0x16
BYTE padding[8 - (trigger_id.cbData + 4)) % 8]; // pad to multiple of 8 bytes; only if header.version >= 0x16
}

Comparing the JobSchedule structure with the GenericData structure, there is a significant overlap of fields (start_boundary, end_boundary, repetition_*, execution_time_limit, stop_tasks_at_duration_end, is_enabled, max_delay_seconds). The most significant difference is the mode and its data1, data2, and data3 fields. Depending on the value of mode, the data fields contain different bitmaps of values which, for example, represent the different days of a month:

• mode 0 (ITimeTrigger): run at <start_boundary>
• mode 1 (IDailyTrigger): run at <start_boundary> and repeat every <data1> days
• mode 2 (IWeeklyTrigger): run on days of week <(data2 as day_of_week bitmap)> every <data1> weeks starting at <start_boundary>
• mode 3 (IMonthlyTrigger): run in months <(data3 as months bitmap)> on days <(data2:data1 as day in month bitmap)> starting at <start_boundary>
• mode 4 (IMonthlyDOWTrigger): run in months <(data3 as months bitmap)> in weeks <(data2 as week bitmap)> on days <(data1 as day_of_week bitmap)> starting at <start_boundary>
struct JobSchedule {
TSTIME start_boundary;
TSTIME end_boundary;
TSTIME unknown0;
DWORD repetition_interval_seconds;
DWORD repetition_duration_seconds;
DWORD execution_time_limit_seconds;
DWORD mode; // see above for possible values
WORD data1;
WORD data2;
WORD data3;
BYTE pad0[2];
BYTE stop_tasks_at_duration_end;
BYTE is_enabled;
BYTE pad1[2];
DWORD unknown1;
DWORD max_delay_seconds;
BYTE pad2[4];
}

Example (only the trigger structure, full example at WnfStateChangeTrigger):

dd dd 00 00 00 00 00 00                         # magic (0xdddd)
01 07 0b 00 00 00 09 00 00 78 18 25 ab 03 c7 01 # start_boundary (localized: yes, filetime: 0x01c703ab25187800; "Thu 9 November 2006 02:00:00 UTC")
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # end_boundary (localized: no, filetime: 0)
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # unknown0
00 00 00 00                                     # repetition_interval_seconds (0)
00 00 00 00                                     # repetition_duration_seconds (0)
ff ff ff ff                                     # execution_time_limit_seconds (0xffffffff)
01 00 00 00                                     # mode (1 -> daily)
01 00                                           # data1 (0x1 -> repeat every 1 day(s))
00 00                                           # data2 (0)
00 00                                           # data3 (0)
00 00                                           # pad0
00                                              # stop_tasks_at_duration_end (false)
01                                              # is_enabled (true)
28 6a                                           # pad1
01 00 00 00                                     # unknown1
10 0e 00 00                                     # max_delay_seconds (3600 "PT1H")
6f a8 28 6a                                     # pad2
48 00 00 00 37 00 64 00 62 00 61 00 31 00 38 00 # trigger_id ("7dba1862-fdda-4030-83de-895375c111d4")
36 00 32 00 2d 00 66 00 64 00 64 00 61 00 2d 00
34 00 30 00 33 00 30 00 2d 00 38 00 33 00 64 00
65 00 2d 00 38 00 39 00 35 00 33 00 37 00 35 00
63 00 31 00 31 00 31 00 64 00 34 00
48 48 48 48                                     # block padding

#### IdleTrigger

The IdleTrigger causes a task to be run whenever the system goes into idle mode. This trigger structure does not have additional fields besides the GenericData.

struct IdleTrigger {
ALIGNED_DWORD magic = 0xEEEE;
GenericData genericData;
}

For an example see RegistrationTrigger.

#### BootTrigger

Tasks with BootTriggers are run by the scheduler when the system boots. These triggers structures do not have any additional fields besides the GenericData.

struct BootTrigger {
ALIGNED_DWORD magic = 0xFFFF;
GenericData genericData;
}

For an example see RegistrationTrigger.

#### GenericData

The GenericData is a set of options which can be modified individually for each trigger. It contains, for example, the range of time in which the trigger is active (start_boundary, end_boundary), whether there should be a delay between activating the trigger and running the task (delay_seconds), or for how long the task instance is allowed to run after being launched (timeout_seconds). The structure also contains the repetition pattern for the task (“repeat task every x hours for the duration of y days”, for example) and a boolean indicating whether all task instances shall be stopped once the repetition_duration has passed. Triggers can also be enabled and may have assigned a trigger_id to make them more recognizable.

struct GenericData {
TSTIME start_boundary;
TSTIME end_boundary;
DWORD delay_seconds;
DWORD timeout_seconds;
DWORD repetition_interval_seconds;
DWORD repetition_duration_seconds;
DWORD repetition_duration_seconds_2; // seems to be always the same value as repetition_duration_seconds; probably a remnant of something no longer implemented, the XML serializer skips this value as well
BYTE stop_at_duration_end;
BYTE padding[3];
ALIGNED_BYTE enabled;
BYTE unknown[8];
BSTR trigger_id; // only if header.version >= 0x16
BYTE pad_to_block[(8 - (trigger_id.len + 4)) % 8]; // only if header.version >= 0x16
}

For an example see WnfStateChangeTrigger.

## Auxiliary Structures

The struct definitions given above use of several other structs to define their content. This section provides the missing definitions in alphabetical order.

struct ALIGNED_BSTR {
ALIGNED_DWORD cbString;
WCHAR str[cbData/2];
BYTE padding[(8-(cbData%8))%8]; // pad to multiple of 8 bytes
}

struct ALIGNED_BSTR_EXPANDSIZE {
ALIGNED_DWORD nChars;
WCHAR str[nChars+1];
BYTE padding[(8-((nChars*2+2)%8))%8]; // pad to multiple of 8 bytes
}

struct ALIGNED_BUFFER {
ALIGNED_DWORD cbData;
BYTE data[cbData];
BYTE padding[(8-(cbData%8))%8]; // pad to multiple of 8 bytes
}

struct BSTR {
DWORD cbData;
WCHAR str[cbData/2];
}

struct TSTIME {
ALIGNED_BYTE isLocalized;
FILETIME time;
}

struct TSTIMEPERIOD {
WORD year;
WORD month;
WORD week;
WORD day; // if used in conjunction with week this is "day of week"
WORD hour;
WORD minute;
WORD second;
}

# Closing Words

Although there are still a few unanswered question (read: unknown elements in structures), the analysis presented in this blog post covers most of the data located in the TaskCache in the Windows registry. However, this post mainly focuses on dissecting and describing the Actions, Triggers, and DynamicInfo blobs as these contain serialized data in binary form. Understanding these binary blobs may be crucial in scenarios where, for example, a malicious party tampered with the data in the registry to hide a certain task from a user.

Definitions for all structures I identified during the analysis can be found on Github together with some tooling based on the code generated by the Kaitai struct compiler. I chose Kaitai as the struct description language so that parsers implemented in different languages can be generated easily. Please refer to the documentation located on Github for more information on how to use the provided structure definitions.