appid.sys — Uninitialized paged pool memory in SmartlockerConstructOriginClaim (CWE-908) fixed
KB5094127
1. Overview
| Field | Value |
|---|---|
| Unpatched binary | appid_unpatched.sys |
| Patched binary | appid_patched.sys |
| Overall similarity | 0.9909 |
| Matched functions | 372 |
| Changed functions | 6 |
| Identical functions | 366 |
| Unmatched (unpatched) | 0 |
| Unmatched (patched) | 0 |
Verdict: The patch contains one security-relevant change and one non-security refactor in the Windows AppID (AppLocker) kernel driver.
-
Finding 1 (security-relevant, Low):
SmartlockerConstructOriginClaimallocates a paged pool buffer withExAllocatePoolWithTagand leaves a few trailing bytes uninitialized. The uninitialized bytes are carried at the full allocation length into a process-token security attribute and a file extended attribute. The patched build adds amemsetthat zeroes the whole buffer, but this zeroing is gated behind the WIL feature flagFeature_965997883; when the feature is disabled the patched code takes the original, unzeroed path. The fix is therefore a staged rollout rather than an unconditionally delivered fix. -
Finding 2 (no security-relevant change): The
wil_details_*feature-state cache functions were refactored from a single global feature descriptor to a per-descriptor context passed as a parameter. The unpatched code already performs a correct lock-free atomic compare-and-swap; there is no genuine data race, and none of these functions participate in an AppLocker allow/deny decision. This is Windows Implementation Library (WIL) feature-staging library churn, not a security fix.
2. Vulnerability Summary
Finding 1: Kernel Information Disclosure via Uninitialized Pool Memory
| Attribute | Value |
|---|---|
| Severity | Low |
| Vulnerability class | Use of Uninitialized Resource (CWE-908); disclosure consequence (CWE-200) |
| Affected function | SmartlockerConstructOriginClaim @ 0x1C001F138 (both builds) |
| Patch mechanism | Adds a memset(buf, 0, size) (zeroing) immediately after ExAllocatePoolWithTag, but only on the path taken when Feature_965997883__private_IsEnabledDeviceUsage @ 0x1C0002194 returns non-zero. When the feature is disabled, the patched code allocates without zeroing (identical to the unpatched behavior). The size base for the arg5 != NULL case is *arg5 + 0x40 when the feature is enabled and *arg5 + 0x42 (the original value) when it is disabled. |
Root cause: The unpatched SmartlockerConstructOriginClaim @ 0x1C001F138 allocates a kernel paged pool buffer using ExAllocatePoolWithTag(PagedPool, size, 'smtA') but never zeroes the buffer. It then selectively initializes specific fields:
[0x00]=1(dword)[0x04]=arg1(dword)[0x08]=0(dword)[0x0C]=1(dword)[0x10–0x1F]= 16 bytes fromBCryptGenRandom[0x20–0x2F]= either a secondBCryptGenRandomcall or a copy of[0x10–0x1F][0x30–0x33]=arg4(dword)[0x34–0x37]= global version/sequence (dword)[0x38–0x3B]= conditional (only written whenarg5is non-NULL)[0x3C+]= conditional data copied viamemmove (sub_1c0006b80)(only whenarg5is non-NULL)
Gaps remain uninitialized:
- When arg5 is NULL, the allocation is 0x40 bytes and only fields up through offset 0x38 (the dword at [rbx+0x38]) are written, leaving bytes 0x3C–0x3F (4 bytes) uninitialized.
- When arg5 is non-NULL, the allocation is *arg5 + 0x42 bytes; fields through 0x38 plus *arg5 bytes copied from offset 0x3C are written, leaving roughly 6 trailing bytes uninitialized.
These gaps contain stale kernel paged pool residue from previously freed allocations.
Data flow:
- A process performs a file execution/creation operation on a system with AppLocker/WDAC enforcement active, routed through the AppID driver, reaching the policy evaluation dispatcher
SmartlockerVerifyProcess@0x1C0020344. SmartlockerVerifyProcesscallsSmartlockerManagedInstallerCheck@0x1C001F25CorSmartlockerSmartscreenProcessTokenCheck@0x1C001F374.- Those helpers call
SmartlockerConstructOriginClaim@0x1C001F138, which returns the buffer via*arg7and its full allocation size via*arg6. - Each caller passes the buffer at that full length to
SmartlockerTagProcessToken@0x1C001F0C0(SmartlockerConstructOriginClaimis called at0x1C001F2DAthenSmartlockerTagProcessTokenat0x1C001F2F6inSmartlockerManagedInstallerCheck; correspondingly at0x1C001F4C6and0x1C001F4E0inSmartlockerSmartscreenProcessTokenCheck). That function wraps{pValue = buffer, ValueLength = size}as aCLAIM_SECURITY_ATTRIBUTEOCTET_STRING and callsSeSetSecurityAttributesToken(0x1C001F11F), placing the uninitialized tail into a process-token security attribute. SmartlockerManagedInstallerCheckalso returns the buffer up throughSmartlockerVerifyProcess, which passes it at the same length toAipSetEaFromOrigin, writing it into a file extended attribute.
Token security attributes and file extended attributes are, by Windows design, queryable from user mode (NtQueryInformationToken(TokenSecurityAttributes), NtQueryEaFile). That final user-mode read is performed by the operating system, not by this driver: the disassembly of appid.sys shows the uninitialized bytes being placed into these two standard Windows objects at full length, but does not itself contain an IRP/IOCTL output-buffer copy back to user mode. The disclosed quantity is small (4–6 bytes of paged pool residue per allocation), which bounds the practical impact.
Finding 2: WIL feature-state cache refactor (no security-relevant change)
| Attribute | Value |
|---|---|
| Severity | None (not a security issue) |
| Nature | Windows Implementation Library (WIL) feature-staging refactor: global feature descriptor moved to a per-descriptor context parameter |
| Affected functions | wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath @ 0x1C00028DC, wil_details_IsEnabledFallback @ 0x1C0002850, Feature_4060024122__private_IsEnabledFallback @ 0x1C0002E00 (unpatched) |
| Change mechanism | Introduces a per-descriptor context pointer (arg3) threaded through the call chain; the callee stops hard-coding the single global feature descriptor wil_details_featureDescriptors_a and instead receives the descriptor pointer as a parameter, reading *arg3 |
Why this is not a security issue: These functions are the WIL feature-enablement state cache embedded in the driver (feature-flag staging plus usage telemetry). They do not participate in any AppLocker allow/deny decision. In the unpatched binary the cache reaches its state through the global pointer wil_details_featureDescriptors_a. wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath @ 0x1C00028DC sets device-usage bits (bit 0x10 for event type 3, bit 0x20 for event type 4) with a lock-free atomic compare-and-swap: it loads the current value at 0x1C00028F9, computes the new value, does lock cmpxchg [r9], edx at 0x1C00028DC+0x32 (0x1C000290E), and on failure the jnz at 0x1C0002913 branches back to 0x1C00028FC to re-read and retry. That is a correct, thread-safe CAS-with-retry loop in both builds — there is no torn read and no lost update, so there is no genuine data race to fix.
The patched version performs the same atomic CAS on *arg3 instead of on the global, plus a lock or [r9], r10d fast path (0x1C0001C89) when the per-descriptor bytes at arg3+0x1d/arg3+0x1e are set. This is a structural library change (global descriptor to per-descriptor context, a 2-argument to 3-argument ABI change across the wil_details_* cluster), not a synchronization fix.
Call chain (for reference):
- A WIL feature-enablement check for
Feature_4060024122reaches the thunkFeature_4060024122__private_IsEnabledFallback@0x1C0002E00, which (unpatched) callswil_details_IsEnabledFallback@0x1C0002850with two arguments, inheriting the global descriptor. wil_details_IsEnabledFallbackreads the global descriptor, reports usage viawil_details_FeatureReporting_ReportUsageToService@0x1C00023EC, then conditionally updates the state bits viawil_details_FeatureStateCache_TryEnableDeviceUsageFastPath@0x1C00028DC.- The patched thunk
Feature_4060024122__private_IsEnabledFallback@0x1C0002E74passes its own descriptor (lea r8, Feature_4060024122__private_descriptorat0x1C0002E78) as the third argument, so each feature uses its own context.
3. Pseudocode Diff
Finding 1: SmartlockerConstructOriginClaim (sub_1C001F138) — Uninitialized Pool Allocation
// ============================================================
// UNPATCHED SmartlockerConstructOriginClaim (sub_1C001F138)
// ============================================================
NTSTATUS SmartlockerConstructOriginClaim (sub_1C001F138)(
ULONG arg1, // event/type
void* arg2,
void* arg3,
ULONG arg4, // stored at [buf+0x30]
DATA* arg5, // optional: { WORD size; ...; void* data; }
ULONG* arg6, // out: allocated size
void** arg7 // out: allocated buffer ptr
) {
ULONG size = 0x40;
if (arg5) {
size = arg5->size + 0x42; // BUG: larger alloc, more uninit tail
}
if (arg1 > 1)
return STATUS_INVALID_PARAMETER;
// *** VULNERABILITY: no zeroing after allocation ***
void* buf = ExAllocatePoolWithTag(PagedPool, size, 'smtA');
if (!buf)
return STATUS_NO_MEMORY;
// Partial initialization — gaps retain stale pool residue
*(DWORD*)(buf + 0x00) = 1;
*(DWORD*)(buf + 0x04) = arg1;
*(DWORD*)(buf + 0x08) = 0;
*(DWORD*)(buf + 0x0C) = 1;
BCryptGenRandom(0, buf + 0x10, 0x10, BCRYPT_USE_SYSTEM_PREFERRED_RNG);
if (arg1 == 0)
memcpy(buf + 0x20, buf + 0x10, 0x10); // copy bytes 0x10-0x1F
else
BCryptGenRandom(0, buf + 0x20, 0x10, BCRYPT_USE_SYSTEM_PREFERRED_RNG);
*(DWORD*)(buf + 0x30) = arg4;
*(DWORD*)(buf + 0x34) = global_version;
if (arg5) {
*(DWORD*)(buf + 0x38) = arg5->size;
memmove (sub_1c0006b80)(buf + 0x3C, arg5->data, arg5->size); // copy trailing data
} else {
*(DWORD*)(buf + 0x38) = 0;
// *** bytes 0x3C-0x3F remain UNINITIALIZED ***
}
*arg6 = size;
*arg7 = buf;
return STATUS_SUCCESS;
}
// ============================================================
// PATCHED SmartlockerConstructOriginClaim (sub_1C001F138)
// ============================================================
NTSTATUS SmartlockerConstructOriginClaim_patched(/* same args */) {
ULONG size = 0x40;
if (arg5) {
BOOL bFeature = Feature_965997883__private_IsEnabledDeviceUsage(); // WIL feature check
size = arg5->size + 0x40;
if (!bFeature)
size += 2; // feature OFF -> +0x42 (original value); feature ON -> +0x40
}
if (arg1 > 1)
return STATUS_INVALID_PARAMETER;
void* buf;
if (Feature_965997883__private_IsEnabledDeviceUsage()) { // feature ON path
buf = ExAllocatePoolWithTag(PagedPool, size, 'smtA');
if (!buf)
return STATUS_NO_MEMORY;
memset(buf, 0, size); // *** THE FIX: zero the whole buffer ***
} else { // feature OFF path — identical to unpatched
buf = ExAllocatePoolWithTag(PagedPool, size, 'smtA');
if (!buf)
return STATUS_NO_MEMORY;
// no zeroing — original behavior retained
}
// ... same partial initialization as before ...
}
The zeroing is delivered only when the WIL feature flag Feature_965997883 is enabled; the feature-off branch retains the original allocate-without-zero behavior. This is a staged rollout.
Finding 2: WIL feature-state cache — global descriptor to per-descriptor context
Both builds perform a correct lock-free atomic compare-and-swap; the only change is where the descriptor comes from (a hard-coded global vs. a parameter).
// ============================================================
// UNPATCHED: single global feature descriptor
// ============================================================
// Thunk
void Feature_4060024122__private_IsEnabledFallback (0x1C0002E00)(arg1, arg2) {
wil_details_IsEnabledFallback(arg1, arg2); // 2 args, inherits the global descriptor
}
// Orchestrator
void wil_details_IsEnabledFallback (0x1C0002850)(ULONG event_type, ULONG arg2) {
wil_details_FeatureStateCache_ReevaluateCachedFeatureEnabledState(
wil_details_featureDescriptors_a, // GLOBAL descriptor (hard-coded)
event_type,
&wil_details_featureDescriptors_a
);
wil_details_FeatureReporting_ReportUsageToService(/* hardcoded feature id */);
wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath(event_type, arg2); // CAS on global
}
// State modifier — correct lock-free CAS, not a race
void wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath (0x1C00028DC)(ULONG event_type, ULONG flags) {
volatile DWORD* pState = wil_details_featureDescriptors_a; // GLOBAL
DWORD old;
do {
old = *pState; // re-read on each retry
} while (lock_cmpxchg(pState, old | bit, old) != old);
// atomic CAS-with-retry: thread-safe, no torn read, no lost update
}
// ============================================================
// PATCHED: per-descriptor context passed as a parameter
// ============================================================
// Thunk
void Feature_4060024122__private_IsEnabledFallback (0x1C0002E74)(arg1, arg2) {
void* ctx = &Feature_4060024122__private_descriptor; // this feature's own descriptor
wil_details_IsEnabledFallback(arg1, arg2, ctx); // 3 args
}
// Orchestrator
void wil_details_IsEnabledFallback (0x1C0001BB0)(ULONG event_type, ULONG arg2, void* ctx) {
wil_details_FeatureStateCache_ReevaluateCachedFeatureEnabledState(*ctx, event_type, ctx);
wil_details_FeatureReporting_ReportUsageToService(ctx);
wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath(event_type, arg2, ctx); // CAS on *ctx
}
// State modifier — same CAS, now on *ctx, plus a lock-or fast path
void wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath (0x1C0001C44)(ULONG event_type, ULONG flags, void* ctx) {
volatile DWORD* pState = (DWORD*)(*ctx); // per-descriptor state
if (ctx[0x1e] || ctx[0x1d]) {
lock_or(pState, bit); // fast path
} else {
// same lock_cmpxchg-with-retry loop, on *ctx
}
}
4. Assembly Analysis
Finding 1: SmartlockerConstructOriginClaim (sub_1C001F138) — Full Unpatched Assembly
; ---- SmartlockerConstructOriginClaim @ 0x1C001F138 ---- (UNPATCHED)
00000001C001F138 mov [rsp+arg_0], rbx
00000001C001F13D mov [rsp+arg_8], rbp
00000001C001F142 mov [rsp+arg_10], rsi
00000001C001F147 push rdi
00000001C001F148 push r14
00000001C001F14A push r15
00000001C001F14C sub rsp, 20h
00000001C001F150 mov rdi, [rsp+38h+arg_20] ; rdi = arg5
00000001C001F155 mov r15d, r9d ; arg4
00000001C001F158 mov ebp, ecx ; arg1
00000001C001F15A mov esi, 40h ; default size = 0x40
00000001C001F15F test rdi, rdi
00000001C001F162 jz short loc_1C001F16A
00000001C001F164 movzx esi, word ptr [rdi] ; size = *arg5
00000001C001F167 add esi, 42h ; size += 0x42
00000001C001F16A mov r14d, 1
00000001C001F170 cmp ebp, r14d
00000001C001F173 jbe short loc_1C001F17F
00000001C001F175 mov eax, 0C000000Dh ; STATUS_INVALID_PARAMETER
00000001C001F17A jmp loc_1C001F23A
00000001C001F17F mov edx, esi ; NumberOfBytes
00000001C001F181 mov r8d, 41746D73h ; Tag 'smtA'
00000001C001F187 mov ecx, r14d ; PoolType = PagedPool (1)
00000001C001F18A call cs:__imp_ExAllocatePoolWithTag ; no zeroing follows
00000001C001F191 nop dword ptr [rax+rax+00h]
00000001C001F196 mov rbx, rax
00000001C001F199 test rax, rax
00000001C001F19C jnz short loc_1C001F1A8
00000001C001F19E mov eax, 0C0000017h ; STATUS_NO_MEMORY
00000001C001F1A3 jmp loc_1C001F23A
00000001C001F1A8 and dword ptr [rax+8], 0 ; buf[0x08] = 0
00000001C001F1AC lea rdx, [rax+10h]
00000001C001F1B0 mov r9d, 2 ; BCRYPT_USE_SYSTEM_PREFERRED_RNG
00000001C001F1B6 mov [rax], r14d ; buf[0x00] = 1
00000001C001F1B9 xor ecx, ecx
00000001C001F1BB mov [rax+4], ebp ; buf[0x04] = arg1
00000001C001F1BE mov [rax+0Ch], r14d ; buf[0x0C] = 1
00000001C001F1C2 lea r8d, [r9+0Eh] ; 0x10 bytes
00000001C001F1C6 call cs:__imp_BCryptGenRandom ; -> buf[0x10..0x1F]
00000001C001F1CD nop dword ptr [rax+rax+00h]
00000001C001F1D2 lea rdx, [rbx+20h]
00000001C001F1D6 test ebp, ebp
00000001C001F1D8 jnz short loc_1C001F1E4
00000001C001F1DA movups xmm0, xmmword ptr [rbx+10h]
00000001C001F1DE movdqu xmmword ptr [rdx], xmm0 ; buf[0x20..0x2F] = buf[0x10..0x1F]
00000001C001F1E2 jmp short loc_1C001F1FA
00000001C001F1E4 xor ecx, ecx
00000001C001F1E6 lea r9d, [rcx+2]
00000001C001F1EA lea r8d, [rcx+10h]
00000001C001F1EE call cs:__imp_BCryptGenRandom ; -> buf[0x20..0x2F]
00000001C001F1F5 nop dword ptr [rax+rax+00h]
00000001C001F1FA mov [rbx+30h], r15d ; buf[0x30] = arg4
00000001C001F1FE mov eax, cs:dword_1C0016660 ; version/sequence
00000001C001F204 mov [rbx+34h], eax ; buf[0x34] = version
00000001C001F207 test rdi, rdi
00000001C001F20A jz short loc_1C001F225
00000001C001F20C movzx eax, word ptr [rdi]
00000001C001F20F lea rcx, [rbx+3Ch]
00000001C001F213 mov [rbx+38h], eax ; buf[0x38] = *arg5
00000001C001F216 movzx r8d, word ptr [rdi] ; Size
00000001C001F21A mov rdx, [rdi+8] ; Src
00000001C001F21E call memmove ; copy *arg5 bytes to buf[0x3C]
00000001C001F223 jmp short loc_1C001F229
00000001C001F225 and dword ptr [rbx+38h], 0 ; arg5==NULL: buf[0x38]=0, buf[0x3C..0x3F] left uninitialized
00000001C001F229 mov rax, [rsp+38h+arg_28] ; arg6
00000001C001F22E mov [rax], esi ; *arg6 = size
00000001C001F230 mov rax, [rsp+38h+arg_30] ; arg7
00000001C001F235 mov [rax], rbx ; *arg7 = buffer ptr
00000001C001F238 xor eax, eax ; STATUS_SUCCESS
00000001C001F23A mov rbx, [rsp+38h+arg_0]
00000001C001F23F mov rbp, [rsp+38h+arg_8]
00000001C001F244 mov rsi, [rsp+38h+arg_10]
00000001C001F249 add rsp, 20h
00000001C001F24D pop r15
00000001C001F24F pop r14
00000001C001F251 pop rdi
00000001C001F252 retn
The patched build at the same address 0x1C001F138 gates both the size adjustment and the zeroing on the WIL feature Feature_965997883:
; ---- SmartlockerConstructOriginClaim @ 0x1C001F138 ---- (PATCHED, allocation region)
00000001C001F160 test rdi, rdi ; arg5?
00000001C001F163 jz short loc_1C001F17E
00000001C001F165 call Feature_965997883__private_IsEnabledDeviceUsage
00000001C001F16A movzx esi, word ptr [rdi] ; size = *arg5
00000001C001F16D xor edx, edx
00000001C001F16F test eax, eax
00000001C001F171 setnz dl ; dl = feature enabled?
00000001C001F174 add esi, 40h ; size += 0x40
00000001C001F177 test edx, edx
00000001C001F179 jnz short loc_1C001F17E ; feature ON -> keep +0x40
00000001C001F17B add esi, 2 ; feature OFF -> +0x42 (original)
00000001C001F17E cmp r14d, 1
00000001C001F182 jbe short loc_1C001F18E
00000001C001F184 mov eax, 0C000000Dh
00000001C001F189 jmp loc_1C001F27C
00000001C001F18E mov ebp, esi
00000001C001F190 call Feature_965997883__private_IsEnabledDeviceUsage
00000001C001F195 mov r8d, 41746D73h ; Tag
00000001C001F19B mov edx, ebp ; NumberOfBytes
00000001C001F19D mov ecx, 1 ; PoolType
00000001C001F1A2 test eax, eax
00000001C001F1A4 jz short loc_1C001F20B ; feature OFF -> allocate without zeroing
00000001C001F1A6 call cs:__imp_ExAllocatePoolWithTag
00000001C001F1AD nop dword ptr [rax+rax+00h]
00000001C001F1B2 mov rbx, rax
00000001C001F1B5 test rax, rax
00000001C001F1B8 jz short loc_1C001F21F
00000001C001F1BA mov r8d, ebp ; Size
00000001C001F1BD xor edx, edx ; Val = 0
00000001C001F1BF mov rcx, rax
00000001C001F1C2 call memset ; *** THE FIX: zero the whole buffer ***
00000001C001F1C7 and dword ptr [rbx+8], 0 ; (shared init from here)
...
00000001C001F20B call cs:__imp_ExAllocatePoolWithTag ; feature OFF path
00000001C001F212 nop dword ptr [rax+rax+00h]
00000001C001F217 mov rbx, rax
00000001C001F21A test rax, rax
00000001C001F21D jnz short loc_1C001F1C7 ; jumps PAST the memset -> no zeroing
00000001C001F21F mov eax, 0C0000017h
00000001C001F224 jmp short loc_1C001F27C
Key observations:
- In the unpatched build the call ExAllocatePoolWithTag at 0x1C001F18A is followed directly by the NULL check at 0x1C001F199 — no zeroing call exists.
- When arg5 is NULL, the branch at 0x1C001F225 only zeroes the dword at [rbx+0x38], leaving bytes [0x3C–0x3F] (the last 4 bytes of the 0x40-byte allocation) uninitialized.
- When arg5 is non-NULL, memmove fills *arg5 bytes from [rbx+0x3C], but the allocation is *arg5 + 0x42, so roughly 6 trailing bytes are left uninitialized.
- In the patched build the memset at 0x1C001F1C2 runs only on the feature-enabled path (0x1C001F1A6). The feature-disabled path (0x1C001F20B) allocates and then jumps to 0x1C001F1C7, skipping the memset — identical to the unpatched behavior. The fix is therefore staged behind the feature flag.
Finding 2: wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath — global descriptor vs. per-descriptor context
The unpatched CAS loop re-reads and retries on failure; it is a correct lock-free atomic operation, not a race.
; ---- wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath @ 0x1C00028DC ---- (UNPATCHED)
00000001C00028DC mov r9, cs:wil_details_featureDescriptors_a ; global descriptor
00000001C00028E3 sub edx, 3
00000001C00028E6 jz short loc_1C00028F3
00000001C00028E8 cmp edx, 1
00000001C00028EB jnz short locret_1C0002915
00000001C00028ED lea r8d, [rdx+1Fh] ; = 0x20 for event type 4
00000001C00028F1 jmp short loc_1C00028F9
00000001C00028F3 mov r8d, 10h ; = 0x10 for event type 3
00000001C00028F9 mov eax, [r9] ; read state (re-read on each retry)
00000001C00028FC test al, 2
00000001C00028FE jz short locret_1C0002915
00000001C0002900 mov edx, eax
00000001C0002902 xor edx, ecx
00000001C0002904 test dl, 1
00000001C0002907 jnz short locret_1C0002915
00000001C0002909 mov edx, eax
00000001C000290B or edx, r8d
00000001C000290E lock cmpxchg [r9], edx ; atomic CAS
00000001C0002913 jnz short loc_1C00028FC ; retry on failure -> correct CAS loop
00000001C0002915 retn
; ---- wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath @ 0x1C0001C44 ---- (PATCHED)
00000001C0001C44 mov r9, [r8] ; per-descriptor state = *arg3
00000001C0001C47 sub edx, 3
00000001C0001C4A jz short loc_1C0001C57
00000001C0001C4C cmp edx, 1
00000001C0001C4F jnz short locret_1C0001C8D
00000001C0001C51 lea r10d, [rdx+1Fh]
00000001C0001C55 jmp short loc_1C0001C5D
00000001C0001C57 mov r10d, 10h
00000001C0001C5D cmp byte ptr [r8+1Eh], 0 ; per-descriptor field
00000001C0001C62 jnz short loc_1C0001C89
00000001C0001C64 cmp byte ptr [r8+1Dh], 0
00000001C0001C69 jnz short loc_1C0001C89
00000001C0001C6B mov eax, [r9]
00000001C0001C6E test al, 2
00000001C0001C70 jz short locret_1C0001C8D
00000001C0001C72 mov edx, eax
00000001C0001C74 xor edx, ecx
00000001C0001C76 test dl, 1
00000001C0001C79 jnz short locret_1C0001C8D
00000001C0001C7B mov edx, r10d
00000001C0001C7E or edx, eax
00000001C0001C80 lock cmpxchg [r9], edx ; same atomic CAS, now on *arg3
00000001C0001C85 jz short locret_1C0001C8D
00000001C0001C87 jmp short loc_1C0001C6E ; retry
00000001C0001C89 lock or [r9], r10d ; fast path
00000001C0001C8D retn
The only functional difference is the state pointer source: cs:wil_details_featureDescriptors_a (a global) versus [r8] (*arg3, a caller-supplied per-descriptor pointer), plus the added lock or fast path gated on the per-descriptor bytes at +0x1d/+0x1e.
Finding 2 (cont.): wil_details_IsEnabledFallback — call sites
; ---- wil_details_IsEnabledFallback @ 0x1C0002850 ---- (UNPATCHED, excerpt)
00000001C0002879 lea r8, wil_details_featureDescriptors_a
00000001C0002880 mov rcx, cs:wil_details_featureDescriptors_a ; hard-coded global
00000001C0002887 call wil_details_FeatureStateCache_ReevaluateCachedFeatureEnabledState
00000001C00028A9 call wil_details_FeatureReporting_ReportUsageToService
00000001C00028BB call wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath
; ---- wil_details_IsEnabledFallback @ 0x1C0001BB0 ---- (PATCHED, excerpt)
00000001C0001BCA mov r14, r8 ; save arg3
00000001C0001BDD mov rcx, [r8] ; descriptor = *arg3
00000001C0001BE0 call wil_details_FeatureStateCache_ReevaluateCachedFeatureEnabledState
00000001C0001C02 mov rcx, r14
00000001C0001C05 call wil_details_FeatureReporting_ReportUsageToService
00000001C0001C12 mov r8, r14
00000001C0001C1A call wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath
Finding 2 (cont.): Feature_4060024122__private_IsEnabledFallback — thunk
; ---- Feature_4060024122__private_IsEnabledFallback @ 0x1C0002E00 ---- (UNPATCHED)
00000001C0002E00 sub rsp, 28h
00000001C0002E04 call wil_details_IsEnabledFallback ; 2 args (inherits global)
00000001C0002E09 add rsp, 28h
00000001C0002E0D retn
; ---- Feature_4060024122__private_IsEnabledFallback @ 0x1C0002E74 ---- (PATCHED)
00000001C0002E74 sub rsp, 28h
00000001C0002E78 lea r8, Feature_4060024122__private_descriptor ; 3rd arg = this feature's descriptor
00000001C0002E7F call wil_details_IsEnabledFallback ; 3 args
00000001C0002E84 add rsp, 28h
00000001C0002E88 retn
5. Trigger Conditions
Triggering Finding 1: Uninitialized Memory Disclosure
- Open a handle to the AppID device interface (
\Device\AppIDor\??\AppID), or trigger evaluation indirectly via a file execution/creation operation on a system with AppLocker enforcement active. - Cause a policy cache miss so that a new rule/cache buffer is allocated. This can be done by referencing a file path that has not been recently evaluated, or by causing cache eviction (flushing).
- Reach
SmartlockerConstructOriginClaim@0x1C001F138via the dispatch chain:SmartlockerVerifyProcess@0x1C0020344→SmartlockerManagedInstallerCheck@0x1C001F25CorSmartlockerSmartscreenProcessTokenCheck@0x1C001F374→SmartlockerConstructOriginClaim. - The
arg5path determines the uninitialized region: - If
arg5 == NULL: allocation is 0x40 bytes; bytes0x3C–0x3Fare never initialized (4 bytes). - If
arg5 != NULL: allocation is*arg5 + 0x42bytes; roughly 6 trailing bytes past the copied data are uninitialized. - Path to observable output: the buffer is returned via
*arg7(pointer) and*arg6(full size), then placed at full length into a process-token security attribute (SeSetSecurityAttributesToken) and a file extended attribute (AipSetEaFromOrigin), both of which are queryable from user mode via standard Windows APIs. - Expected effect: No crash. A few bytes of paged-pool residue are carried into those objects. Under a debugger, dumping the buffer at the return point shows the uninitialized tail.
Triggering Finding 2
Finding 2 is not a security defect (see section 2). For reference, concurrent WIL feature-enablement checks for Feature_4060024122 reach Feature_4060024122__private_IsEnabledFallback @ 0x1C0002E00 → wil_details_IsEnabledFallback @ 0x1C0002850 → wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath @ 0x1C00028DC. Both callers perform a lock cmpxchg on the same global dword, but this is a correct lock-free CAS with a re-read-and-retry loop: the loser of a CAS re-reads the current value and retries, so no state is lost or torn. No inconsistent or corrupted state results, and no AppLocker allow/deny decision is involved.
6. Exploit Primitive & Development Notes
Finding 1: Uninitialized paged pool disclosure
- Primitive: Disclosure of a small amount of stale kernel paged pool memory — 4 bytes (when
arg5 == NULL) or roughly 6 trailing bytes (whenarg5 != NULL) per allocation. - What is established from the binaries: The uninitialized tail is copied at the full allocation length into (a) a process-token security attribute via
SmartlockerTagProcessToken→SeSetSecurityAttributesToken, and (b) a file extended attribute viaAipSetEaFromOrigin. Both object types are queryable from user mode by design (NtQueryInformationToken(TokenSecurityAttributes),NtQueryEaFile), but that user-mode read is performed by the operating system, not byappid.sys; there is no IRP/IOCTL output-buffer copy of the buffer inside this driver. - Bounds on impact: The disclosed quantity is at most 4–6 bytes per allocation. The contents are whatever paged-pool residue previously occupied that block; the disassembly does not establish that these bytes contain any specific class of data (pointers, tokens, etc.). Claims of a KASLR bypass or a token/EPROCESS harvest are not supported by the binaries and are not made here.
- Mitigation relevance: This is a read/disclosure of uninitialized memory (CWE-908), not a write or a code-execution vector; SMEP/SMAP/HVCI/CFG are not relevant.
- Fix status: The patched
memsetcloses the gap only when the WIL featureFeature_965997883is enabled; with the feature disabled the patched driver behaves exactly as the unpatched one. This is a staged rollout, not an unconditional fix.
Finding 2: WIL feature-state cache refactor (no exploit primitive)
- No primitive. The unpatched atomic compare-and-swap is a correct lock-free operation (re-read and retry on failure); there is no genuine race to exploit. The state involved is WIL feature enablement/usage telemetry, never an AppLocker allow/deny decision. The global-to-per-descriptor change is a library refactor with no security impact.
7. Debugger PoC Playbook
For Finding 1: Uninitialized Pool Allocation
Breakpoints:
bp appid!SmartlockerConstructOriginClaim (sub_1C001F138)+0x52 ; = 0x1c001f18a — ExAllocatePoolWithTag call
bp appid!SmartlockerConstructOriginClaim (sub_1C001F138)+0xed ; = 0x1c001f225 — "and dword [rbx+0x38], 0" (NULL arg5 path, bytes 0x3C-0x3F leaked)
bp appid!SmartlockerConstructOriginClaim (sub_1C001F138)+0x100 ; = 0x1c001f238 — STATUS_SUCCESS return (buffer fully populated)
If symbols are not available, use raw offsets from the module base:
bp <appid_base>+0x1f18a ; ExAllocatePoolWithTag call
bp <appid_base>+0x1f225 ; NULL-arg5 uninit path
bp <appid_base>+0x1f238 ; return point (inspect buffer here)
What to inspect:
| Breakpoint | Register/Memory | Purpose |
|---|---|---|
+0x1f18a (pre-alloc) |
edx = allocation size (esi); ecx = 1 (PagedPool); r8d = 'smtA' tag |
Confirm allocation parameters |
+0x1f196 (post-alloc) |
rax/rbx = allocated buffer pointer |
Record the pool address for later inspection |
+0x1f225 (NULL-arg5 path) |
rbx+0x3C through rbx+0x3F |
These 4 bytes should contain stale pool data |
+0x1f238 (return) |
rbx = final buffer; inspect dq rbx L8 |
Dump full buffer to see all uninitialized gaps |
Commands to run at the return breakpoint:
; Dump the full buffer (0x40 bytes = 8 qwords)
dq rbx L8
; Focus on bytes 0x3C-0x3F (last dword)
dd rbx+0x3c L1
; Check if the leaked content looks like a kernel pointer
!pool rbx
Key instruction offsets:
| Offset | Instruction | Significance |
|---|---|---|
+0x52 (0x1C001F18A) |
call cs:__imp_ExAllocatePoolWithTag |
The allocation; no zeroing follows in the unpatched build |
Between +0x52 and the NULL check |
(absent) | No zeroing call. The patched build inserts a memset at 0x1C001F1C2, but only on the feature-enabled path (Feature_965997883) |
+0xED (0x1C001F225) |
and dword ptr [rbx+38h], 0 |
Only zeroes [+0x38]; [+0x3C..0x3F] left uninitialized |
Trigger setup:
- Enable AppLocker policy enforcement on the target system (or use WDAC).
- From a user-mode test harness, open
\Device\\AppIDor simply execute a file that triggers policy evaluation:c CreateProcessW(L"C:\\Windows\\System32\\notepad.exe", ...); - The call chain is: file operation → kernel AppID interception →
SmartlockerVerifyProcess@0x1C0020344(dispatcher) →SmartlockerManagedInstallerCheck@0x1C001F25C/SmartlockerSmartscreenProcessTokenCheck@0x1C001F374→SmartlockerConstructOriginClaim@0x1C001F138.
Expected observation:
- At the return breakpoint,
dq rbx L8will show the buffer. Bytes at offset0x3C–0x3F(the last dword) may contain non-zero residue from a previously freed pool block whenarg5 == NULL— these are the uninitialized bytes. - Use
!pool rbxto inspect the pool block. - No crash — this is a silent disclosure of a few bytes.
Struct/offset notes:
| Offset (from buffer base) | Size | Initialized by | Notes |
|---|---|---|---|
0x00 |
dword | constant 1 |
Magic/type |
0x04 |
dword | arg1 (event type) |
|
0x08 |
dword | constant 0 |
|
0x0C |
dword | constant 1 |
|
0x10 |
16 bytes | BCryptGenRandom |
Random bytes |
0x20 |
16 bytes | BCryptGenRandom or copy of 0x10 |
Depends on arg1 == 0 |
0x30 |
dword | arg4 |
|
0x34 |
dword | global_version (data_1c0016660) |
|
0x38 |
dword | *arg5 or 0 |
|
0x3C–0x3F |
4 bytes | UNINITIALIZED (when arg5 == NULL) |
Leaked pool data |
For Finding 2: WIL feature-state cache refactor (not a security issue)
Finding 2 is a WIL feature-staging refactor with no security impact, so there is no exploit to reproduce. For inspection of the relevant code:
Breakpoints (RVAs from the image base):
bp appid!wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath ; unpatched entry @ 0x1C00028DC
bp <appid_base>+0x290e ; lock cmpxchg dword ptr [r9], edx (0x1C000290E) — the atomic CAS
The instruction at 0x1C00028DC (mov r9, cs:wil_details_featureDescriptors_a) loads the global feature descriptor pointer, and the CAS at 0x1C000290E is followed by jnz 0x1C00028FC (retry). Observing this loop under contention shows a correct lock-free compare-and-swap: on failure it re-reads the current value and retries, so no update is lost. This is expected, correct behavior, not a race, and no security-relevant state can be corrupted.
Expected observation:
- The write breakpoint on the global state dword will fire from multiple thread contexts.
- The CAS loop (
lock cmpxchg) may occasionally fail and retry under contention. This is the expected, correct behavior of a lock-free CAS: the retry re-reads the current value, so no update is lost and no state is corrupted. - No inconsistent or torn state results; there is no security-relevant effect to observe.
Struct/offset notes:
| Symbol | Reference | Description |
|---|---|---|
wil_details_featureDescriptors_a |
mov r9, cs:wil_details_featureDescriptors_a at 0x1C00028DC |
Global feature descriptor pointer (unpatched) |
State bit 0x10 |
— | Set for event type 3 |
State bit 0x20 |
— | Set for event type 4 |
8. Changed Functions — Full Triage
Security-Relevant Changes
| Function | Similarity | Change Type | Summary |
|---|---|---|---|
SmartlockerConstructOriginClaim @ 0x1C001F138 |
0.8129 | Security-relevant | Pool allocation without zeroing → adds a memset that zeroes the buffer, gated behind Feature_965997883; the size base for the arg5 != NULL case is 0x40 when the feature is on and 0x42 (original) when off. Feature-off path is identical to the unpatched code (staged rollout). |
WIL Feature-Staging Refactor (not security-relevant)
The following four functions form a single WIL (Windows Implementation Library) feature-staging refactor: the callee stops hard-coding the single global feature descriptor and instead receives a per-descriptor context pointer as a parameter. The underlying atomic compare-and-swap is already correct in both builds, and none of these functions touch an AppLocker allow/deny decision.
| Function | Similarity | Change Type | Summary |
|---|---|---|---|
wil_details_FeatureStateCache_TryEnableDeviceUsageFastPath @ 0x1C00028DC |
0.8176 | Refactor (non-security) | Atomic CAS retargeted from the global descriptor to *arg3; adds a lock or fast path when per-descriptor bytes [arg3+0x1d]/[arg3+0x1e] are set. The unpatched CAS is already a correct lock-free loop. |
wil_details_IsEnabledFallback @ 0x1C0002850 |
0.9535 | Refactor (non-security) | Takes new arg3 (descriptor pointer); passes it to child functions instead of using the global descriptor. |
Feature_4060024122__private_IsEnabledFallback @ 0x1C0002E00 |
0.6736 | Refactor (non-security), thunk | Adds a 3rd arg (lea r8, Feature_4060024122__private_descriptor); relocated to 0x1C0002E74 in the patched binary. |
wil_details_FeatureReporting_ReportUsageToServiceDirect @ 0x1C00022FC |
0.9765 | Refactor (non-security) | Hardcoded feature id and global config table → reads them from the context pointer (*(arg1+0x18), *(arg1+8)). |
wil_details_FeatureReporting_ReportUsageToService @ 0x1C00023EC |
0.9897 | Refactor (non-security) | Hardcoded id and global dispatch table → reads id from *(arg1+0x18), config from *(arg1+0x10), callback from dword_1C0016230. Same event-type mapping logic. |
The differences in the two ReportUsage* functions are limited to register reallocation and the substitution of immediate constants with context-pointer-relative loads; the control flow and arithmetic logic are identical.
9. Unmatched Functions
No functions were added or removed in the patched binary (unmatched_unpatched: 0, unmatched_patched: 0). The patch is entirely in-place — all changes are modifications to existing functions, not additions of new security checks or removal of vulnerable helpers.
This is notable because it means the zeroing memset was inserted inline into SmartlockerConstructOriginClaim @ 0x1C001F138 rather than factored into a new wrapper function, and the WIL per-descriptor context refactor was accomplished by rethreading parameters through the existing call chain rather than introducing new dispatch functions.
10. Confidence & Caveats
Confidence: High (for Finding 1), High (for Finding 2 being a non-issue)
Finding 1 (uninitialized memory, Low):
- The unpatched assembly shows ExAllocatePoolWithTag followed by partial initialization with no intervening zeroing call, leaving 4 bytes uninitialized when arg5 == NULL and roughly 6 trailing bytes otherwise. This is unambiguous.
- The patched build adds a zeroing memset, but only on the path taken when Feature_965997883 is enabled; the feature-disabled path allocates without zeroing (identical to unpatched). The fix is a staged rollout.
- The uninitialized tail is carried at full length into a process-token security attribute (SeSetSecurityAttributesToken) and a file extended attribute (AipSetEaFromOrigin). Both object types are user-mode-queryable by Windows design, but that final read is performed by the OS; the driver itself does not copy the buffer to a user IRP/IOCTL output. The disclosed quantity is small (4–6 bytes), which keeps the practical severity Low.
Finding 2 (WIL feature-state refactor, not a security issue):
- Both builds use the same lock-free atomic compare-and-swap with a re-read-and-retry loop; there is no genuine data race. The only change is that the descriptor is passed as a per-descriptor context parameter (arg3) rather than read from the single global descriptor.
- These functions manipulate only WIL feature enablement/usage telemetry bits; none participate in an AppLocker allow/deny decision. There is no security impact.
What a researcher should verify manually:
- Finding 1: Confirm whether
Feature_965997883is enabled by default in the shipped configuration; if disabled, the patched driver leaves the uninitialized-allocation path in place. - Finding 1: Determine, on a live system, whether the residue placed into the token security attribute and file extended attribute is in practice queryable by an unprivileged process (
NtQueryInformationToken(TokenSecurityAttributes),NtQueryEaFile), and whether the 4–6 uninitialized bytes ever carry sensitive content. - Finding 1: Driver Verifier with special pool can be used to confirm the use of uninitialized memory.
- All: The
0x1C00xxxxaddresses in this analysis are RVAs from the preferred image base, not absolute runtime addresses; confirm the loaded module base at runtime.