SetFileSecurityW changes control bits from 0x8404 to 0x9004 / 0xb004: can DACL-only operations restore the original state?

Krystal Bei 0 Reputation points
2026-09-20T11:13:34.3933333+00:00

I am investigating Windows security-descriptor behavior in a synthetic directory test.

The test machine currently reports Windows 11, version 25H2, OS build 26200.9457.

Operation and observations

SetFileSecurityW returned success when called with:

SECURITY_INFORMATION = 0x80000004

(DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION)

The supplied self-relative security descriptor had SECURITY_DESCRIPTOR_CONTROL = 0x8404. The protection request was passed through the SECURITY_INFORMATION flags.

Security-descriptor readbacks showed:

  • Query mask 7 (0x00000007): 0x8404 before; 0x9004 after.
  • Query mask 8 (0x00000008): 0x8000 before; 0x8000 after.
  • Query mask 65551 (0x0001000F): 0x8404 before; 0xb004 after.

Mask 7 requests owner, group, and DACL. Mask 8 requests SACL. Mask 65551 combines BACKUP_SECURITY_INFORMATION with owner, group, DACL, and SACL information.

In views 7 and 65551, the returned DACL bytes matched the intended input DACL, and SE_DACL_PROTECTED was set. Owner, group, and SACL content matched the retained baseline.

Two subsequent read-only observation rounds produced consistent results. The other four synthetic objects matched their retained baselines within the observed scope.

Why the test stopped

The test assumed that each view would preserve its baseline control bits and add SE_DACL_PROTECTED (0x1000). It therefore expected 0x9404 in views 7 and 65551.

The actual control values did not satisfy that assumption, so the test stopped before attempting restoration. Each view was compared with its own baseline; the test did not require different query views to be identical.

I have not established that the assumption of preserving all other control bits is guaranteed by the API. Unchanged SACL content also does not establish that SACL protection state is unchanged.

Questions

  1. Under what conditions can this SetFileSecurityW call clear SE_DACL_AUTO_INHERITED (0x0400) while setting SE_DACL_PROTECTED (0x1000)? Is this documented behavior, and which control bits are guaranteed to be preserved?
  2. Why might query mask 65551 report SE_SACL_PROTECTED (0x2000), producing 0xb004, while the SACL-only query remains 0x8000? How can I distinguish a persisted SACL protection change from query-specific representation?
  3. With the original baseline descriptors retained, can supported DACL-only operations restore the original control state, including 0x8404 in views 7 and 65551, without writing owner, group, or SACL and without changing other objects? If so, what APIs, flags, privileges, and preconditions are required?
  4. Is byte-for-byte restoration of each queried self-relative descriptor a supported expectation, or should validation compare specific semantic fields instead?

I would appreciate documentation references and a suggested minimal reproduction using new disposable objects. The stopped test has not been restored or retried.

This post contains no raw ACLs/security descriptors, actual paths, account identifiers, production data, or attachments.

Windows development | Windows API - Win32
0 comments No comments

1 answer

Sort by: Most helpful
  1. Taki Ly (WICLOUD CORPORATION) 5,630 Reputation points Microsoft External Staff Moderator
    2026-09-21T02:12:00.5366667+00:00

    Hello @Krystal Bei ,

    Thanks for the very thorough write-up. I ran your scenario on Windows 11 build 26200 (NTFS) with throwaway parent/child directories, both normally and elevated so I could read the SACL views as well. What you're seeing comes from the API you're calling, not from the descriptor semantics changing underneath you.

    Below is what I got. The baseline child directory (inheriting from its parent) read back as 0x8404 on mask 7, 0x8800 on mask 8 and 0x8C04 on mask 0x1000F.

    • SetFileSecurityW(DACL | PROTECTED_DACL) with SD control 0x8404 -> mask 7 = 0x8004, mask 8 = 0x8800, mask 0x1000F = 0x8804
    • SetFileSecurityW(DACL) with an SD whose control already has SE_DACL_PROTECTED -> mask 7 = 0x9004, mask 8 = 0x8800, mask 0x1000F = 0x9804
    • SetFileSecurityW(DACL | UNPROTECTED_DACL) -> mask 7 = 0x8004, mask 8 = 0x8800, mask 0x1000F = 0x8804 (no restore)
    • SetNamedSecurityInfoW(DACL | PROTECTED_DACL) -> mask 7 = 0x9404, mask 8 = 0x8800, mask 0x1000F = 0x9C04
    • SetNamedSecurityInfoW(DACL | UNPROTECTED_DACL) -> mask 7 = 0x8404, mask 8 = 0x8800, mask 0x1000F = 0x8C04, DACL bytes identical to baseline

    Two things stand out. First, SetFileSecurityW simply ignores PROTECTED_DACL_SECURITY_INFORMATION / UNPROTECTED_DACL_SECURITY_INFORMATION, and it drops SE_DACL_AUTO_INHERITED on every DACL write. The 0x9004 you got is exactly what I get when the input descriptor's control word already carries SE_DACL_PROTECTED – so it may be worth logging the control word of the buffer you actually pass in. Second, the 0x9404 your test expected is precisely what SetNamedSecurityInfoW produces, and the same API with UNPROTECTED_DACL_SECURITY_INFORMATION takes it back to 0x8404 with a byte-identical mask-7 descriptor.

    The reason is that SetFileSecurity is marked obsolete in the docs ("Use the SetNamedSecurityInfo function instead"), and the same page notes that security applied through it to a directory is not inherited by children. It's essentially a thin wrapper over NtSetSecurityObject: it stores what you give it and never runs the auto-inheritance algorithm, so the system can't keep claiming the DACL is auto-inherited and clears that bit. The SECURITY_INFORMATION page also says outright that some of its members "work only with the SetNamedSecurityInfo function" – the PROTECTED/UNPROTECTED flags are those members. SetNamedSecurityInfoW, by contrast, applies the ACE inheritance rules to the object and its children, which is why it gives you 0x9404 and can restore 0x8404.

    On your specific questions:

    1. With SetFileSecurityW, SE_DACL_AUTO_INHERITED is cleared on any DACL write, protected or not. Nothing in the docs promises that control bits survive a set; the control word is recomputed (see SECURITY_DESCRIPTOR_CONTROL for the bits). Only SetNamedSecurityInfo / SetSecurityInfo document inheritance handling, and in practice they keep SE_DACL_AUTO_INHERITED when protecting.
    2. The control word you read back is built for the subset you asked for, not echoed from storage. In my elevated runs the 0x1000F control was always just mask-7 OR mask-8 (0x8404 | 0x8800 = 0x8C04, 0x9004 | 0x8800 = 0x9804, and so on), and no DACL-only write through either API ever changed the mask-8 view – even when I deliberately put SE_SACL_PROTECTED in the input control word. So a DACL-only call isn't persisting SACL protection. To tell persisted state from representation, compare the same mask before and after and look at semantic fields (SE_SACL_PRESENT, the SACL ACEs, the P on the S: part of the SDDL) rather than the raw control value. I'll be honest that I couldn't reproduce your 0xb004 – 0x2000 showing up in the 0x1000F view while mask 8 stays 0x8000. My baseline mask 8 was 0x8800 (SE_SACL_AUTO_INHERITED) rather than your 0x8000, so your objects were created or last written differently. If you can share the full SDDL of the 0x1000F view before and after, and confirm the mask-8 query ran with SeSecurityPrivilege enabled (without it GetFileSecurity fails with ERROR_PRIVILEGE_NOT_HELD), I'm happy to dig further.
    3. Yes, a DACL-only restore works:
         SetNamedSecurityInfoW(path, SE_FILE_OBJECT,
             DACL_SECURITY_INFORMATION | UNPROTECTED_DACL_SECURITY_INFORMATION,
             NULL, NULL, pDacl, NULL);
      
      You need WRITE_DAC on the object (or to be its owner) – no SeSecurityPrivilege, no owner/group/SACL writes. The system re-enables inheritance, recomputes the inherited ACEs from the parent, sets SE_DACL_AUTO_INHERITED, and only propagates to that object's descendants. I got 0x8404 back (0x8C04 on 0x1000F, same as baseline) with the mask-7 bytes identical to the original.
    4. Byte-for-byte equality isn't something the API promises. Compare owner SID, group SID, the ACE set (type, flags including INHERITED_ACE, mask, SID) and the protected/auto-inherited flags per section – SDDL per section via GetSddlForm or ConvertSecurityDescriptorToStringSecurityDescriptor is the practical way. Inherited ACE order and generic-right mapping are system-computed and can legitimately differ between writes.

    If you want a minimal repro: create %TEMP%\sdrepro\child, read mask 7 (0x8404), call SetNamedSecurityInfoW with DACL | PROTECTED_DACL (0x9404), then with DACL | UNPROTECTED_DACL (0x8404, identical DACL). Repeat the two calls with SetFileSecurityW and you'll see 0x8004 / 0x9004 with no way back. Run it elevated with SeSecurityPrivilege if you also want masks 8 and 0x1000F. The Modifying the ACLs of an Object sample shows the SetNamedSecurityInfo pattern if useful.

    So this is by-design behaviour of the legacy SetFileSecurity path rather than a bug. Move the protect/unprotect/restore steps to SetNamedSecurityInfo and you'll get the 0x9404 / 0x8404 values your test was written for, and I'd switch the validation to semantic comparison rather than raw bytes.

    If you found my response helpful or informative, I would greatly appreciate it if you could follow this guide so others can find it, and if something still differs on your side, let me know.

    Thank you.

    Was this answer helpful?


Your answer

Answers can be marked as 'Accepted' by the question author and 'Recommended' by moderators, which helps users know the answer solved the author's problem.