ipt.sys OpenImageIFEOKey access check bypass

Image File Execution Options is the registry tree under HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options that the loader consults every time it launches a process. Each subkey names an image — consent.exe, osk.exe, the binary behind a scheduled task or auto-elevated executable — and the loader honours, among other values, the Debugger string. If Debugger is set on an image’s IFEO key, the loader does not launch the image; it launches the program named in Debugger, passing the original command line as an argument. Whoever can write a Debugger value for a privileged image controls what runs the next time that image is launched, and the debugger inherits the original image’s privileged context. The registry ACL on the IFEO hive is therefore the only barrier between a low-privileged user and EoP via this path, and it is enforced correctly: low-privileged SIDs cannot create or modify keys under that hive.

ipt.sys is a helper driver for image-policy lookups, and OpenImageIFEOKey is the routine that opens or creates the per-image IFEO key on behalf of a user-mode request. It takes a fully caller-controlled image name, walks the buffer backward to the last \\ to strip any directory prefix, and uses the remaining basename to build a registry path of the form HKLM\...\Image File Execution Options\<image> relative to a cached root handle. A byte-sized CreateNotOpen argument selects ZwCreateKey (set) against ZwOpenKey (clear). The shape that matters is the OBJECT_ATTRIBUTES handed to the registry manager. Two attribute bits govern how a kernel caller is treated: OBJ_KERNEL_HANDLE (0x200) makes the handle process-independent, and because the call originates in kernel mode the object manager treats the caller as trusted kernel code and never evaluates the calling thread’s token; OBJ_FORCE_ACCESS_CHECK (0x400) overrides that, directing the object manager to run SeAccessCheck against the caller’s token despite the kernel-mode call site. The contract for any driver that opens a user-named object on behalf of a user request is to set both bits — kernel-safe handle and user access evaluated. The vulnerable build set only one:

/* OpenImageIFEOKey -- vulnerable build
 *   arg1 OutHandle     : PHANDLE         (resulting HKEY)
 *   arg2 ImageName     : PUNICODE_STRING (caller-supplied; basename is taken)
 *   arg3 SubKeyName    : PUNICODE_STRING (optional; opened under the per-image key)
 *   arg4 CreateNotOpen : UCHAR           (nonzero -> ZwCreateKey, zero -> ZwOpenKey)
 */

/* Walk ImageName back to the last L'\\'; the tail becomes ObjectName,
 * the cached IFEO root handle in data_1400073e0 becomes RootDirectory. */
End = (PWSTR)((PUCHAR)ImageName->Buffer + ImageName->Length);   /* arg2->Buffer +0x08 */
while (End > ImageName->Buffer && End[-1] != L'\\')             /* +0x08               */
    End--;
BaseName.Length         = (USHORT)((PUCHAR)ImageName->Buffer + ImageName->Length - (PUCHAR)End);
BaseName.MaximumLength  = BaseName.Length;
BaseName.Buffer         = End;

Oa.Length                   = 0x30;                   /* +0x00, sizeof(OBJECT_ATTRIBUTES) */
Oa.RootDirectory            = IFEO_RootKey;           /* +0x08, cached HKLM\...\IFEO handle */
Oa.ObjectName               = &BaseName;              /* +0x10, basename UNICODE_STRING */
Oa.Attributes               = OBJ_CASE_INSENSITIVE
                            | OBJ_KERNEL_HANDLE;      /* +0x18, hardcoded 0x200|0x40 = 0x240 */
Oa.SecurityDescriptor       = NULL;                   /* +0x20 */
Oa.SecurityQualityOfService = NULL;                   /* +0x28 */

if (CreateNotOpen) {
    status = ZwCreateKey(&Handle,
                         0x3,                        /* KEY_QUERY_VALUE | KEY_SET_VALUE */
                         &Oa,
                         0, NULL, 0, NULL);          /* SeAccessCheck never invoked */
} else {
    status = ZwOpenKey  (&Handle,
                         0x3,                        /* same mask, same no-check attributes */
                         &Oa);                       /* SeAccessCheck never invoked */
}

The registry manager sees a kernel-originated open with no force-check bit, trusts the kernel-mode caller, and returns a handle carrying KEY_QUERY_VALUE | KEY_SET_VALUE without ever evaluating the calling thread’s token against the IFEO key’s security descriptor. KEY_SET_VALUE is exactly the right needed to write the Debugger string, so the returned handle is a writable IFEO key handed to whichever unprivileged SID asked for it. The CreateNotOpen selector did exist in the vulnerable build — it picked ZwCreateKey against ZwOpenKey — but it had no second job: the same 0x240 attribute word was stored to both branches, so the no-check path was identical for the create-new and open-existing requests. The ACL that should have denied a low-privileged user was simply not consulted.

The bypass is local, unprivileged, and persistent. An attacker with no special token and no physical access calls into ipt.sys from a low-IL process, has the driver create (or open) an IFEO key for an image that the system will later launch in a privileged context (a scheduled task, a service helper, an auto-elevated binary), sets Debugger = <payload> through the returned handle, and waits. The next time that image launches the loader finds the Debugger value and runs the payload under the privileged context. The registry ACL is enforced correctly everywhere else; the driver simply arranged for the ACL not to be asked on this path.

The patch

/* OpenImageIFEOKey -- patched
 * The CreateNotOpen byte now also gates OBJ_FORCE_ACCESS_CHECK: the
 * create path (the one that can materialise a brand-new IFEO key for a
 * user-named image) carries the bit; the open path keeps the kernel-trust
 * default. */
Oa.Length                   = 0x30;                   /* +0x00 */
Oa.RootDirectory            = IFEO_RootKey;           /* +0x08 */
Oa.ObjectName               = &BaseName;              /* +0x10 */
Oa.Attributes               = OBJ_CASE_INSENSITIVE
                            | OBJ_KERNEL_HANDLE;      /* +0x18, 0x240 baseline */
if (CreateNotOpen)
    Oa.Attributes |= OBJ_FORCE_ACCESS_CHECK;          /* +0x400 -> 0x640 on the create path */
Oa.SecurityDescriptor       = NULL;                   /* +0x20 */
Oa.SecurityQualityOfService = NULL;                   /* +0x28 */

if (CreateNotOpen) {
    status = ZwCreateKey(&Handle,
                         0x3,
                         &Oa,
                         0, NULL, 0, NULL);          /* caller token now evaluated (0x640) */
} else {
    status = ZwOpenKey  (&Handle,
                         0x3,
                         &Oa);                       /* internal open path, still 0x240 */
}

The same change is applied on the top-level per-image key and the optional sub-key under it — SubKeyName rebuilds the same OBJECT_ATTRIBUTES template beneath the freshly created per-image handle, and the same conditional 0x400 is folded into Attributes before the second ZwCreateKey/ZwOpenKey. Both call sites originally shared the no-check 0x240. The kernel-trust default is preserved on the ZwOpenKey branch; the ZwCreateKey branch — the one that can materialise a brand-new IFEO key for a user-named image — now carries OBJ_FORCE_ACCESS_CHECK, so the object manager evaluates the user’s token despite the kernel-mode call site. When you see a driver add 0x400 to OBJECT_ATTRIBUTES.Attributes under a flag that already existed, the old code was re-using a kernel-trust attribute on a user-reachable request, and grep’ing the same module for other OBJ_KERNEL_HANDLE opens that take user-supplied names is usually how the next instance turns up.

Attack path

flowchart TD
    A["low-privileged process"] --> B["send a privileged image name into ipt.sys"]
    B --> C["OpenImageIFEOKey strips the basename, builds OBJECT_ATTRIBUTES"]
    C --> D["Attributes hardcoded 0x240, OBJ_FORCE_ACCESS_CHECK missing"]
    D --> E["ZwOpenKey or ZwCreateKey returns a handle with KEY_SET_VALUE; SeAccessCheck never sees the IFEO ACL"]
    E --> F["attacker writes Debugger on a SYSTEM-launched image, waits for launch, payload inherits the privileged context"]

IFEO is an auto-elevation surface; any path that lets a low-privileged user write an IFEO key is an EoP. The bug was one flag bit, the fix is one flag bit, and the consequence of the missing bit is total compromise of the boundary.