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 hasSE_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:
- With
SetFileSecurityW,SE_DACL_AUTO_INHERITEDis 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). OnlySetNamedSecurityInfo/SetSecurityInfodocument inheritance handling, and in practice they keepSE_DACL_AUTO_INHERITEDwhen protecting. - 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_PROTECTEDin 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, thePon theS: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 withSeSecurityPrivilegeenabled (without itGetFileSecurityfails withERROR_PRIVILEGE_NOT_HELD), I'm happy to dig further. - Yes, a DACL-only restore works:
You needSetNamedSecurityInfoW(path, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | UNPROTECTED_DACL_SECURITY_INFORMATION, NULL, NULL, pDacl, NULL);WRITE_DACon the object (or to be its owner) – noSeSecurityPrivilege, no owner/group/SACL writes. The system re-enables inheritance, recomputes the inherited ACEs from the parent, setsSE_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. - 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 viaGetSddlFormorConvertSecurityDescriptorToStringSecurityDescriptoris 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.