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 ignores PROTECTED_DACL_SECURITY_INFORMATION / UNPROTECTED_DACL_SECURITY_INFORMATION, and it drops SE_DACL_AUTO_INHERITED on a DACL write unless the input descriptor's control word has SE_DACL_AUTO_INHERIT_REQ (0x0100) set. 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, and the protection and inheritance behavior comes from the control word of the descriptor you pass in, not from the SECURITY_INFORMATION flags. If that control word has SE_DACL_PROTECTED, the DACL is stored as protected. If it has SE_DACL_AUTO_INHERIT_REQ, the system recomputes the inherited ACEs from the parent and sets SE_DACL_AUTO_INHERITED. If it has neither, the DACL is stored as given and the auto-inherited bit is cleared. 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, unless the input control word carriesSE_DACL_AUTO_INHERIT_REQ. Nothing in the docs promises that control bits survive a set; the control word is recomputed (see SECURITY_DESCRIPTOR_CONTROL for the bits). WithSetFileSecurityW, inheritance handling is driven by those control bits.SetNamedSecurityInfo/SetSecurityInfoalso accept the PROTECTED/UNPROTECTED flags, 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:
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. Pass the DACL from your retained baseline here, not the current protected one. After protection, the formerly inherited ACEs are explicit, so passing the current DACL leaves duplicate explicit ACEs.
Alternatively, with SetFileSecurityW itself: take the retained baseline descriptor, set SE_DACL_AUTO_INHERIT_REQ with SetSecurityDescriptorControl(pSD, SE_DACL_AUTO_INHERIT_REQ, SE_DACL_AUTO_INHERIT_REQ), and call SetFileSecurityW(path, DACL_SECURITY_INFORMATION, pSD). In my tests on build 26200 this restored 0x8404 from 0x9004, with a byte-identical mask-7 descriptor for both a directory and a file.
- 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 with SetFileSecurityW and you'll see 0x8004 (PROTECTED_DACL flag only) or 0x9004 (SE_DACL_PROTECTED in the input control). Writing the baseline descriptor back with SE_DACL_AUTO_INHERIT_REQ added returns it to 0x8404. 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. It acts on the control bits of the descriptor you pass, not on the PROTECTED/UNPROTECTED flags. Either move the protect/unprotect/restore steps to SetNamedSecurityInfo, which gives the 0x9404 / 0x8404 values your test was written for, or keep SetFileSecurityW and set SE_DACL_PROTECTED / SE_DACL_AUTO_INHERIT_REQ explicitly in the input control word. Either way, 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.