Skip to content

[BUG] ops_write_file 新建的文件只留一条调用者显式 ACE、丢掉父目录全部继承 ACE:刚建的文件同一会话删不掉,跨用户/工具访问也被切断 #420

Open

Description

@Alasxjl

摘要

ops_write_file 新建的文件只带一条显式 ACE PC-…\Administrator:(F),没有从父目录继承任何 ACE(连 DSH 自己给 workspace 授权的沙箱写 SID S-1-4-<hash>:(OI)(CI)(W,D,DC) 都没有)。后果不是“权限稍微严一点”,而是两个实际故障:

  1. 自己能建、自己不能删:同一会话里 rm 刚由 ops_write_file 创建的文件 → Permission denied(DSH 侧提示 [sandbox: file access denied under workspace-write mode]),因为受限令牌的写 SID 在该文件的 DACL 里没有 ACE;
  2. 继承被切断:父目录的 Authenticated Users:(OI)(CI)(IO)(M)、Users:(OI)(CI)(IO)(GR,GE)、Administrators:(OI)(CI)(IO)(F) 全部丢失,多用户/服务账户/后续工具的访问会失败,与本工具描述里的 “Windows preserves owner/group/ordered DACL” 给人的预期相反(preserve 只对被覆盖的旧文件成立,新建时是替换)。

覆盖写一个已存在的文件时 DACL 不会被修好——它保持那条孤儿 ACE 不变,所以“先建后删/先建后改名”的构建流程会一直卡住。

环境

项 值
OS Windows 10 专业版 19045.4291(NTFS 卷)
DSH 0.2.1-rc.3(Desktop)
dsh-ops 0.4.1
会话策略 workspace-write,审批 ask(期间未使用 danger-full-access)
工作区 普通目录,本身已由 DSH 授权:S-1-4-<hash>:(OI)(CI)(W,D,DC) + Mandatory Label\Low:(OI)(CI)(NW)

最小复现(A/B 对照,同一目录)

A:ops_write_file 新建
    { "path": "_del_probe_a.txt", "content": "x\n", "expectedRevision": "absent" }
    → write_file: completed; … metadata={"created":true}

B:沙箱内 bash 新建(对照组)
    printf 'x\n' > _del_probe_b.txt

DACL 对比(icacls 原样输出):

A  _del_probe_a.txt  PC-20250512ZCYU\Administrator:(F)          ← 显式 ACE,无 (I)
                     Mandatory Label\Low Mandatory Level:(I)(NW)

B  _del_probe_b.txt  S-1-4-<hash>:(I)(W,D,DC)                    ← 来自父目录继承
                     BUILTIN\Administrators:(I)(F)
                     NT AUTHORITY\SYSTEM:(I)(F)
                     NT AUTHORITY\Authenticated Users:(I)(M)
                     BUILTIN\Users:(I)(RX)
                     Mandatory Label\Low Mandatory Level:(I)(NW)

删除测试(同一会话、同一令牌):

rm -f _del_probe_b.txt   → rc=0            ✅
rm -f _del_probe_a.txt   → Permission denied / [sandbox: file access denied under workspace-write mode]  ❌

再做一次覆盖写(expectedRevision 给 A 的当前 revision):metadata={"attributes":true,"dacl":true,"group":true,"owner":true} 回报一切正常,但 icacls 显示 A 仍是 Administrator:(F) 一条 —— 覆盖写不会把继承修回来。

作为反证:宿主进程(非沙箱通道)写的 *.md 在同样目录里沙箱可以正常 rm,说明问题只在 ops_write_file 新建这条路径上,不是 workspace 授权缺失。

机制线索(供作者确认)

  • native/crates/ops-files/src/ 里 grep 不到 任何 DACL / SECURITY_ATTRIBUTES / SetSecurityInfo / SetNamedSecurityInfo 调用;file_publication.rs:29/153-161 用的是 create(true) / create_new(true),cache_io.rs:44-45 用 create_new(true).mode(0o600)(mode 在 Windows 上无意义)。
  • 因此“只留一条调用者 ACE 的显式 DACL”不是 ops-files crate 自己设的,更像是发布阶段把某个临时件(staging/temp,DACL 只给当前用户)改名/复制到工作区后继承了它的安全描述符,或由宿主侧“preserve metadata”步骤显式写入。真正的低完整性标签 Low:(I)(NW) 是继承来的,说明文件确实落在工作区父目录下 —— 两种解释都能解释标签,但只有“显式/受保护 DACL”能解释继承 ACE 全丢。
  • 这条如果成立,它和“原子发布”的实现方式强相关(file_publication.rs 的 stage-then-publish)。

建议修复

  1. 新建/发布时不要带显式或受保护 DACL:去掉 PROTECTED_DACL_SECURITY_INFORMATION,让新文件按父目录继承;若确实需要从 staging 复制,则在 publish 时用 SetFileSecurity 重算继承(SE_DACL_AUTO_INHERITED)。
  2. 加一条回归测试:ops_write_file 新建后,文件的 DACL 必须包含父目录的所有可继承 ACE(至少沙箱写 SID),并且同一会话能删除该文件。
  3. 顺手校正文档:现在写的是 “Windows preserves owner/group/ordered DACL”,容易被读成“新建也会按目录策略落 ACL”;建议明确“覆盖保留 / 新建继承”。

相关

清理

A/B 两个探针文件已删除(A 因上述缺陷需要用更宽权限删除,正好是这条 bug 的自证)。


/cc @T-Auto 这条想请作者看一下修复方向是否认同(尤其是“发布时显式 DACL 导致继承丢失”这个判断),需要的话我可以补更细的追踪(Procmon 或 handle 级证据)。

Activity

  1. cryaciccl commented on Oct 27, 2026

    @cryaciccl

    定位已确定:设 DACL 的不是 ops-files,而是同仓 native/windows-metadata 的 create_private;缺的是「新建分支」发布后的继承回落

    主帖「机制线索」里说在 crates/ops-files/src grep 不到 SetSecurityInfo —— 这点是对的,因为设 DACL 的代码在同仓另一个 crate native/windows-metadata。按主帖那条 /cc,这里补上精确链路、可复现解释,以及一个不破坏「暂存期保密」的修法。

    确切链路(0.4.1,源码随包发布)

    ① 暂存件一律带受保护 DACL

    native/crates/ops-files/src/file_publication.rs:99-106,create_sibling 的 Windows 分支把 source 直接丢掉:

    #[cfg(windows)]
    {
        let _ = source;
        let parent = parent.try_clone()?.into_std_file();
        dsh_ops_windows_metadata::create_private(&parent, temporary)

    ② create_private 的 DACL 是显式构造的

    native/windows-metadata/src/lib.rs:87-89 的文档注释自陈(原文):

    /// CREATE_NEW relative to a held directory, with a protected current-process
    /// user DACL at creation, before any bytes can be written. No inherited grants.
    

    :106-119 的 private_descriptor() 最终 SDDL:

    D:P(A;;FA;;;<当前进程用户 SID>)
    

    D:P 即 受保护 DACL,一条 ACE、inherited=False —— 与主帖 icacls 输出逐字吻合。

    ③ 新建与覆盖的差别,决定「覆盖也修不回来」

    新建路径 native/crates/ops-files/src/file_single.rs:377-378:

    #[cfg(windows)]
    let preserved = source.map(|source| publication::Preserved::capture(source, false)).transpose()?;

    新文件 source == None → preserved == None → :392-401 的 preserved.restore(&file) 整段跳过;随后 :435 用 hard_link 发布,安全描述符随 inode 一起过去。

    覆盖路径 native/crates/ops-files/src/file.rs:297 先 Preserved::capture(&checked, true) 抓目标原有的 DACL,:311 再 restore 回去。所以覆盖一个已坏的文件只是把那条孤儿 ACE 原样搬回去 —— 这正是主帖观察到的「覆盖写不会把继承修回来」。

    顺带解释了 metadata={"dacl":true} 为何如实回报成功:它确实「保留」了,只是保留的对象本身是错的。

    私有暂存是对的,缺的是发布后的继承回落

    我不建议主帖建议 1 的「新建时不要带受保护 DACL」:暂存期间若正常继承,同目录的其他主体/受限进程能读到写了一半的内容,而 create_private 的注释表明这是有意设计。(create_cache_object 的 write_sid 分支更是必须保持私有。)

    缺的是新文件发布之后的一步:此时字节已完整,不必再保密,应把父目录的可继承 ACE 施加到目标上。

    修法草案

    windows-metadata 里已有全部原语(descriptor() = GetSecurityInfo、sddl()、SetSecurityDescriptorControl、SetKernelObjectSecurity),新增一个同族函数即可:

    /// Give a just-published object the ACEs it would have inherited from its directory.
    pub fn adopt_parent_dacl(parent: &File, child: &File) -> io::Result<()> {
        let parent_sd = descriptor(parent)?;
        // 只保留能作用到文件上的可继承 ACE:
        // 有 OBJECT_INHERIT_ACE(OI) 的,以及既无 OI 也无 CI 的;
        // 容器专用(CI 且无 OI)的跳过,否则会比继承多给。
        // 同时清掉 INHERITED_ACE / INHERIT_ONLY 位。
        let dacl = /* 按上述规则过滤 parent_sd 的 DACL */;
        let working: LocalBox<SecurityDescriptor> = /* 以 dacl 组装,D: 后不带 P */;
        unsafe { SetKernelObjectSecurity(child.as_raw_handle(), DACL_SECURITY_INFORMATION, &working) }
    }

    调用点:file_single.rs::publish_new,source.is_none() 的 Windows 分支,在 hard_link(:435)之后、sync_applied 之前。不要动 file.rs(覆盖,必须保留目标 SD)和 create_cache_object(缓存件就该私有)。

    两个实现坑:

    • 不要用「传 NULL DACL 让系统补算继承」那套:pDacl == NULL 且置了 SE_DACL_PRESENT 时会把 DACL 设成 NULL,等于 everyone 完全控制。必须显式构造父目录的可继承集。
    • 只清 SE_DACL_PROTECTED 不会让 Windows 自动补 ACE(重算只发生在父目录变更传播或显式 auto-inherit 请求时),所以还是得主动施加一次;SE_DACL_AUTO_INHERITED 那一位写不写无所谓,verify_cache 只比对 D: 之后的部分。

    回归测试建议

    ops_write_file(expectedRevision: "absent")新建到工作区后:

    1. 文件 DACL 必须包含父目录的全部可继承 ACE(至少 DSH 的 S-1-4-<hash>:(OI)(CI)(W,D,DC));
    2. 同一会话、同一受限令牌能 rm 掉它;
    3. 反向断言:覆盖一个 icacls /inheritance:r 过的文件后其 DACL 不变 —— 防止把 1 的修法做成「所有写入都重算 ACL」,破坏 W1a/W1b 那类显式 ACE / deny 保留语义。

    补充实测(可作对照)

    同一台机、同一工作区、同样 workspace-write:

    • ops_write_file 新建 → 1 条非继承 ACE,rm 失败;
    • DSH 内置 write(走 dsh-fs-local 的 writeFileAtomic,同样是暂存 + rename)没有这个问题:新文件 ACE 全部 inherited=True 且含沙箱能力 SID,rm 成功。差别只在 dsh-fs-local 的暂存目录是普通目录(ACE 继承自父目录),而 create_private 显式设了受保护 DACL。

    所以既不是 OS 行为,也不是 DSH 工作区授权缺失,范围收敛在 create_private 产出物的新建发布路径上。

    需要的话我可以按上面草案提 PR(含 windows-metadata 单测 + 第 3 条反向断言)。本机 Windows 10 19045 + dsh-ops 0.4.1,也能顺手在真实受限令牌下做端到端验证。

  2. cryaciccl commented on Oct 27, 2026

    @cryaciccl

    已按上面的链路实现并验证,PR:#438

    • windows-metadata 新增 adopt_parent_dacl(parent, child):只取父目录带 OBJECT_INHERIT_ACE 的 ACE,以未保护 DACL 写回;创建者 FA 头一条,发布永不收窄发布者自己。
    • publish_new 在 hard_link 之前、字节写完之后调用它(source.is_none() 的 Windows 分支)。
    • 覆盖路径与 create_cache_object 不动。

    验证:cargo test --workspace --locked 55 passed / 0 failed / 1 ignored(既有 #[ignore]);新增两个用例,撤掉调用后 new_file_adopts_directory_grants 变红(14 passed; 1 failed)。端到端:把重编 worker 换进已安装插件后,ops_write_file 新建文件的 DACL 从 1 条变成 7 条,含 S-1-4-…(W,D,DC) 能力 ACE。

  3. cryaciccl commented on Oct 27, 2026

    @cryaciccl

    补上受限令牌下的 A/B(同一台机、同一工作区、workspace-write 沙箱,只换 worker 二进制):

    # 原始 worker 新建 → 沙箱内删不掉
    $ icacls sbx-before.txt
    sbx-before.txt DESKTOP-6TRVKKJ\super:(F)
                   Mandatory Label\Low Mandatory Level:(I)(NW)
    $ rm -v sbx-before.txt
    rm: cannot remove 'sbx-before.txt': Permission denied   # exit 1,文件仍在
    
    # 补丁版 worker 新建 → 沙箱内可删
    $ icacls sbx-after.txt
    sbx-after.txt DESKTOP-6TRVKKJ\super:(F)
                  S-1-4-569614992-901634307:(W,D,DC)
                  ...
    $ rm -v sbx-after.txt
    removed sbx-after.txt   # exit 0

    另一点值得写进 release note:修复只作用于新建文件,已存在的旧文件不会自愈(实测旧文件换装补丁版后仍 Permission denied),需要 icacls <file> /inheritance:e 一次性恢复继承。PR:#438

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions