Skip to content

fix(file): inherit the directory DACL when publishing a new file - #438

Open
cryaciccl wants to merge 1 commit into
T-Auto:mainfrom
cryaciccl:fix/new-file-inherits-directory-dacl
Open

cryaciccl wants to merge 1 commit into
T-Auto:mainfrom
cryaciccl:fix/new-file-inherits-directory-dacl

Conversation

@cryaciccl

@cryaciccl cryaciccl commented Oct 27, 2026 •

Copy link
Copy Markdown

Closes #420

现象 / What was wrong

ops_write_file 新建(expectedRevision: "absent")的文件只带一条非继承 ACE:

$ icacls ab-before-fix.txt
ab-before-fix.txt DESKTOP-6TRVKKJ\super:(F)
                  Mandatory Label\Low Mandatory Level:(I)(NW)

同一个工作区里,内置 write 建出的文件有 6 条继承 ACE(含沙箱能力 SID S-1-4-…)。后果是受限 token 连自己刚写的文件都删不掉——workspace-write 下的 token 只有能力 SID 授权,不含用户 SID——其他工具与用户也被同样切断。

环境:Windows 10 19045 / dsh-ops 0.4.1 / workspace-write(main 上同样复现)。最小复现就是工作区里 ops_write_file 新建一个文件再 icacls。

#420 的「机制线索」猜是 staging 改名带过来的安全描述符,实测不是。确切链路:

  • crates/ops-files/src/file_publication.rs:99-106:create_sibling 的 Windows 分支丢弃 source,一律调 dsh_ops_windows_metadata::create_private;
  • windows-metadata/src/lib.rs:106-119:private_descriptor() 生成 D:P(A;;FA;;;<当前进程用户>),D:P = 受保护、无继承项,经 OBJECT_ATTRIBUTES.SecurityDescriptor 在 FILE_CREATE 时下发;
  • crates/ops-files/src/file_single.rs:377-378:新建路径的 preserved 来自 source.map(...),新文件 source == None,因此发布后没有任何一步恢复继承,hard_link 把该 inode 的安全描述符原样带走;
  • crates/ops-files/src/file.rs:297:覆盖路径先 Preserved::capture(目标) 再 restore,只会把目标原有的 DACL 搬回去,所以坏文件覆盖后仍是坏的。

改动 / The change

windows-metadata/src/lib.rs — 新增 adopt_parent_dacl(parent, child):读父目录 DACL,只取带 OBJECT_INHERIT_ACE 的允许/拒绝 ACE(容器专用 ACE 不作用于文件,对象/回调 ACE 带 GUID 作用域,重建会丢作用域,故跳过),以 SetKernelObjectSecurity(…, DACL_SECURITY_INFORMATION, …) 写回未保护的 DACL。创建者自身的 FA 头一条,因此发布永不收窄发布者自己的访问。

crates/ops-files/src/file_single.rs — publish_new 在 hard_link 之前、字节写完之后,对 source.is_none() 的 Windows 分支调用它;失败则丢弃暂存件并返回 METADATA_UNSUPPORTED(与既有元数据失败语义一致)。

file_publication.rs — metadata_error 提到 pub(super) 以便复用同一个错误映射。

刻意没做:

  • 没有去掉 create_private 的私有 DACL。暂存期私有是正当防护(半截字节不该被读到),缺的只是「发布后回落」;去掉它会泄露写入中的内容。
  • 没有动覆盖路径与 create_cache_object(缓存件必须保持私有)。
  • 没有在 adopt_parent_dacl 里手工拼 D:P 之外的 ACL 语义,也没有用 NULL DACL 让系统补算(pDacl == NULL 且置了 SE_DACL_PRESENT 会得到 everyone 完全控制)。

新增 dev-dependency windows-permissions = "=0.2.4"(测试用,仅 cfg(windows)):这是 workspace 里 dsh-ops-windows-metadata 已经在用的同一精确版本,不引入新下载、不改变任何发布代码路径,Cargo.lock 只多一行依赖边。

Validation

$ cargo test --workspace --locked          # Windows 10 19045, rustc 1.99.1
running 2 tests  ... test result: ok. 2 passed
running 19 tests ... test result: ok. 19 passed
running 15 tests ... test result: ok. 15 passed; 0 failed
running 6 tests  ... test result: ok. 6 passed
running 4 tests  ... test result: ok. 4 passed
running 9 tests  ... test result: ok. 8 passed; 1 ignored   # 既有 #[ignore] unc_directory
合计 55 passed / 0 failed / 1 ignored

新增断言(-p ops-files):

test file::single::tests::new_file_adopts_directory_grants ... ok
test file::single::tests::a_directory_without_inheritable_grants_still_publishes_an_accessible_file ... ok
  • new_file_adopts_directory_grants:目录里放一条带唯一掩码 0x10001 的可继承 ACE,发布后断言产物 DACL 里存在「带 INHERITED_ACE 标记、同一掩码、同一 SID」的 ACE,即发布后确实继承了目录授权。撤掉 adopt_parent_dacl 调用后此用例变红。
  • a_directory_without_inheritable_grants_still_publishes_an_accessible_file:父目录是受保护 DACL、没有任何可继承 ACE 时,产物仍可被发布者读回并删除(防止修法在「无可继承项」场景把创建者 ACE 一起丢掉)。

端到端(把本分支重编的 worker 换进已安装插件后,用真正的 ops_write_file 造文件):

$ icacls ab-after-fix.txt
ab-after-fix.txt DESKTOP-6TRVKKJ\super:(F)
                 S-1-4-569614992-901634307:(W,D,DC)
                 BUILTIN\Administrators:(F)
                 NT AUTHORITY\SYSTEM:(F)
                 NT AUTHORITY\Authenticated Users:(M)
                 BUILTIN\Users:(RX)
                 Mandatory Label\Low Mandatory Level:(I)(NW)

7 条与目录的可继承集合逐条对应(改前只有第 1 条 + 标签)。

受限令牌下的 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
exit=1
rm: cannot remove 'sbx-before.txt': Permission denied
$ ls sbx-before.txt
sbx-before.txt

# 本分支重编的 worker 新建的文件
$ icacls sbx-after.txt
sbx-after.txt DESKTOP-6TRVKKJ\super:(F)
              S-1-4-569614992-901634307:(W,D,DC)
              BUILTIN\Administrators:(F)
              ...
$ rm -v sbx-after.txt
removed 'sbx-after.txt'
exit=0

同一个受限令牌,改前 Permission denied 且文件仍在,改后 removed。

CI run(pull_request 事件):https://github.com/T-Auto/dsh-ops/actions/runs/40526360681 —— 当前 conclusion=action_required,首次贡献需维护者批准后才会真正执行。

已知局限与升级注意:修复只作用于新建文件,已存在的旧文件不会自愈(实测:用原始 worker 建的文件在换装补丁版后仍 Permission denied),需要 icacls <file> /inheritance:e 一次性恢复继承;覆盖路径保持原语义(抓目标旧 DACL 再写回),所以旧文件被覆盖也仍是旧的 DACL。撤掉实现后的那次运行读数是 14 passed; 1 failed。

Staging objects are created with a protected, creator-only DACL so partial
bytes are never readable. The create path never restored inheritance after
publication, so a new file carried exactly one explicit ACE: a confined
workspace token authorizes through inherited capability grants and therefore
could not unlink a file it had just written, and every other grant the
directory carries was lost.

publish_new now adopts the directory's object-inherit ACEs before the hard
link is made, which is after the bytes are complete, so the private staging
DACL keeps its purpose. The creator keeps full control, so publication never
narrows the publisher's own access. Overwrites are unchanged: they still
capture and restore the target's own descriptor.

Refs T-Auto#420
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant