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.