Files
BakNRet/docs/security-descriptor.md
Shuery 60ecbc2933 docs: README 拆出四篇主题文档,本文件留总览与操作
拆出软件名录(67 行)、清单语法(114 行)、归档布局与迁移(47 行)、安全描述符(69 行),共 297 行;README 从 653 降到 360 行,每节原位留一句摘要加链接。

为什么拆:这四块是「要查的资料」,README 剩下的是「要读的流程」。混在一起时,想查清单语法的人得先滚过一百多行总览;拆开后每篇也能被单独引用与单独评审。

搬运按标题抽取原文、不重打,并在写之前断言正文长度、写之后再读回断言一次(第一次尝试就是栽在没有断言上:@(a, b, $arr) 不会展开数组,而是把 $arr 拼成一行,结果四篇各只有 5 行、正文却已从 README 删除。那次已回滚)。
2026-09-27 10:20:08 +08:00

4.7 KiB
Raw Permalink Blame History

安全描述符(属主 / ACL)

本文从 README.md 拆出,单独成篇是为了让它能被单独引用与单独评审。

问题:归档格式装不下 NTFS 安全描述符 —— 7-Zip 的 -sni(Store NT security information) 官方文档写明「当前版本只能写进 WIM 归档」,.7z 里一个字节的 ACL 都没有。 于是"备份 → 恢复"之后,每个对象的安全描述符都是新建对象的默认值:属主是跑恢复脚本的 那个进程,DACL 是从目标父目录继承来的那一套。

为什么这对 C:\ProgramData 是致命的:那里的目录 ACL 里有

(A;OICIIO;GA;;;CO)        CREATOR OWNER + inherit-only + GENERIC_ALL

CREATOR OWNER(S-1-3-0)不是账户,是访问检查时才替换的占位符 —— 替换成 "被检查对象的属主"。所以这句话的真实含义是「谁创建的东西谁有全权」。只回放 ACE 文本、 不恢复属主,等于把里面的"谁"换成了跑恢复脚本的账户,原程序(服务账户 / 专用用户) 反而没了读写权限。真机实测(tools\lab\Lab.ps1 acl-test):

原属主       = S-1-5-18        (NT AUTHORITY\SYSTEM)
恢复后属主   = S-1-5-18        ← 正确恢复(要靠显式启用的 SeRestorePrivilege)
负对照属主   = S-1-5-32-544    ← 只搬文件、不回放安全描述符时,属主落到"跑脚本的账户"

怎么做:

  • 备份时把每个对象的 SDDL(Get-Acl 的原文,含 O: / G: / D:)写进旁挂文件 Backups/<归档名>.acl.json,键是归档内相对路径(目标机器上 %UserProfile% 和 名录的前缀补全都会变,只有归档内路径两端同源)。
  • SDDL 里的 SID 是数值形式,CO / OW 这类占位符原样保留。全程不做账户名解析 —— 名字解析会把占位符映射成当前用户,或者直接抛 IdentityNotMappedException, 那正是"权限落到脚本头上"的另一种成因。
  • 恢复时在解压之后、对真实目标路径自顶向下回放:父目录先写,子对象的继承才收敛。 原本不 protected 的 DACL 只写显式 ACE,其余交给父目录重新继承(保住活继承语义); protected 的原样写。
  • 写属主要 SeRestorePrivilege,而且必须显式启用:管理员的过滤令牌里它默认是 disabled, Set-Acl / SetAccessControl 都不会替你打开。所以恢复要在管理员(或 SYSTEM)下跑, 脚本启动时会明确告警"属主将无法恢复,只能恢复 DACL"。
  • 写失败有三级回退:属主+属组+DACL → 属主+DACL → 仅 DACL(属组常常是最先失败的那个, 而它对访问判定几乎没影响,不能因为它把属主一起丢掉)。
  • 对象的安全描述符读不到(系统目录里很常见)时带错误记账、写进 sidecar 并计入 manifest 的 security.errors,恢复时跳过它并告警 —— 而不是当成"这个对象没有特殊权限"。

配置在 BackupConfig.psd1:

Security = @{
    Mode        = 'Full'     # Off | Full | Smart | Roots
    IncludeSacl = $false     # 连审计规则(SACL)一起存取,需要 SeSecurityPrivilege
    SidMap      = @{}        # 跨机恢复的 SID 映射:@{ 'S-1-5-21-旧' = 'S-1-5-21-新' }
    FailOnError = $false     # sidecar 写不出来时,是否把该条目算作失败
}
  • Full(默认):每个对象都存。正确性优先,几万文件的树 sidecar 几 MB。
  • Smart:只存"继承复现不出来"的对象(protected / 有显式 ACE / 属主属组与父目录不同 / 继承链已脱节)。判据偏保守,但终究是启发式,所以不是默认。
  • Roots:只存每个归档项的根,最省。
  • Off:完全不采集,恢复出来的就是新建对象的默认值。

Restore.ps1 另有 -SkipSecurity 可以只恢复文件内容。

已知取舍(有意为之):

  • 归档旁边没有 acl.json 的旧归档照常恢复,只是打一行告警说明"属主/ACL 是默认值"。
  • 陈旧继承 ACE 会被"冻结":如果某个对象的 DACL 里留着已经没有任何出处的继承 ACE (父目录改过权限、Windows 自己也不会再传播它),那它靠继承复现不出来,只能整套冻结成 显式 ACE 并置 protected —— 这是唯一"既不丢 ACE、也不产生重复 ACE"的做法(实测: 目标上原本就留着那条陈旧 ACE,再补一条显式 ACE 会让同一条 ACE 出现两次)。 代价是这个对象从此不跟随父目录,而它本来就已经跟父目录脱节了。
  • ACL 只跟着归档旁边的 acl.json 走:搬归档时要把同名的 .acl.json 一起搬。