死磕一个 Windows 右键“新建菜单”锁定 Bug:从权限地狱到终于跑通

这次修 ContextMenuMgr 的 #17,我是真的被 Windows 注册表权限按在地上摩擦了一整轮。最开始看起来只是一个普通 Bug:
新建菜单排序后,“新建文件夹”和“库”会消失。
结果一路追下去,发现它根本不是一个简单的排序问题,而是 ShellNew 排序、ACL 锁定、用户上下文、服务权限、前后端架构差异 全搅在了一起。
最后能修好,靠的不是“换条路绕过去”,而是把整条链路拆开,一点一点确认:既然能锁上,那就一定能解开。
一、问题表象:新建菜单一编辑就坏
一开始的问题很直观:
用户在“新建菜单”页面启用排序、调整顺序后,Windows 右键菜单里的:
1 | 新建文件夹 |
可能会消失。
重启后 Windows 有时会把它们恢复回来,但排序和锁定状态又丢了。再重新锁定、排序,问题又出现。
这类现象很容易误判成“Windows 自己刷新新建菜单导致状态回滚”,但真正的问题在于:
程序写入 ShellNew 排序列表时,把不可移动的系统项过滤掉了。
Windows 新建菜单的排序主要看这个位置:
1 | HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Discardable\PostSetup\ShellNew |
其中 Folder、.library-ms、.lnk 这些项目虽然不应该让用户随便拖动,但它们仍然必须保留在 Classes 里。当前代码也确实围绕 ShellNewOrderPath 读取 Classes 排序。
最早的问题就是把:
1 | CanMove == false |
误解成了:
1 | 不需要保存进 Classes |
但这是错的。
CanMove 只应该控制 UI 能不能移动,不能控制它是否被写回注册表。
二、对照 BluePointLilac:模型能抄,访问方式不能抄
为了确认正确行为,我去对照了 BluePointLilac/ContextMenuManager。
它的思路很简单:
1 | 锁定新建菜单: |
这个模型本身是对的。
但这里有一个致命差异:
1 | BluePointLilac: |
也就是说,在 ContextMenuMgr 里,所有用户级注册表操作都不能直接用:
1 | Registry.CurrentUser |
而应该明确写到:
1 | HKEY_USERS\<前端用户 SID>\Software\Microsoft\Windows\CurrentVersion\Explorer\Discardable\PostSetup\ShellNew |
最新实现里,锁定、修复、排序都已经接收 BackendUserContext,并通过用户 SID 定位目标用户 hive,这是整条链路能稳定工作的前提。
三、最折磨人的部分:能锁上,但解不开
最离谱的阶段是:
锁定成功了,但解锁失败。
一开始我差点怀疑是不是权限不够。但日志里后端明明是:
1 | NT AUTHORITY\SYSTEM |
既然 SYSTEM 都在跑,权限理论上肯定够。真正的问题不是权限不够,而是打开注册表 key 的权限请求方式错了。
我们给 ShellNew key 加的锁是:
1 | Everyone Deny Delete | WriteKey |
关键坑在这里:WriteKey 不是一个很“单纯”的写值权限,它会影响某些后续访问行为。于是有一版代码在锁上后又想用:
1 | RegistryRights.ReadPermissions |
去读 ACL 做验证,结果被自己刚加的 Everyone Deny WriteKey 卡住。
这就出现了非常荒谬的现象:
1 | SetAccessControl 成功 |
最终修法是:
1 | 锁定后如果 ACL 读不出来,不应该直接判失败; |
最新实现里,TryReadShellNewOrderLockState 在 ACL 不可读时会把状态视为 locked,而不是像旧代码一样返回 false。
四、另一个坑:解锁前不能 CreateSubKey
中间还有一版补丁,方向看起来对了,但还是失败。日志显示它在解锁时炸在:
1 | CreateSubKey(... writable: true) |
这其实也很合理。
当 key 已经被加了:
1 | Everyone Deny WriteKey |
那么你在解锁之前做:
1 | CreateSubKey(ShellNewOrderPath, writable: true) |
就等于试图以可写方式打开一个已经被锁住的 key。
这一步会被自己的锁拦住。
所以最终链路必须严格分开:
1 | 解锁 / 修复 ACL: |
最新代码里,排序流程就是:
1 | RemoveShellNewOrderLock |
也就是先解锁、再写顺序、最后自动复锁。
五、最终稳定链路
最后跑通的设计大概是这样:
1. 锁定新建菜单
1 | ResetShellNewOrderAcl |
其中锁规则保持和 BluePointLilac 兼容:
1 | Everyone Deny Delete | WriteKey |
2. 解锁新建菜单
1 | ResetShellNewOrderAcl |
它会移除坏的 Deny 规则,并尽可能恢复可用的 unlocked 状态。
3. 修复新建菜单权限
1 | RepairShellNewOrderAclAsync |
这个按钮不依赖当前 UI 判断是否锁定。
因为之前最大的问题就是:ACL 已经坏了,但 UI 状态可能显示不出来。
4. 排序
1 | 确认当前是锁定状态 |
其中 allRealItems 很关键,它保证 Folder、.library-ms、.lnk 这种系统项不会被过滤掉。
六、这次学到的东西
1. HKCU 在服务里不是你以为的 HKCU
只要项目是“前端当前用户 + 后端服务”,那后端里的:
1 | Registry.CurrentUser |
就要极度警惕。
用户级注册表必须显式转成:
1 | HKEY_USERS\<用户 SID> |
否则看起来写成功了,其实可能写到了服务账户的 HKCU。
2. ACL 的“读权限”也可能被自己的锁挡住
这次最坑的地方就是:
1 | 锁写成功了 |
这类场景不能简单写成:
1 | if (!Verify()) throw; |
尤其是 ACL 本身就是为了限制访问时,验证逻辑必须允许“不可读但已锁”的情况存在。
3. 解锁路径绝对不能先 writable-open
如果 key 已经被锁住,那么解锁流程不能一上来就:
1 | CreateSubKey(... writable: true) |
否则就是自己把自己挡住。
正确顺序必须是:
1 | ChangePermissions 打开 |
4. 不可移动不等于不保存
这是最初的业务 bug。
1 | CanMove=false |
只意味着用户不能拖它。
它仍然可能是系统必需项,必须写回 Classes。否则 新建文件夹、库、快捷方式 就会被你自己“排序排序没了”。
七、结尾
这次 Bug 最恶心的点在于:它不是一个单点错误,而是几个错误叠在一起:
1 | CanMove 语义误用 |
每一个单独看都像“小问题”,但叠起来就是:
能锁上,解不开;
能修复,又复发;
BluePointLilac 能救一次,但下一轮又被新补丁搞坏。
最后能跑通,靠的是把“新建菜单”这条链路完整拆开:
1 | 枚举 |
然后每一步都问清楚:
当前操作的是哪个用户的注册表?
当前 key 是锁着还是解锁?
现在请求的权限会不会被自己的 Deny 拦住?
现在写回的是全部真实项,还是只写了可移动项?
这次修完以后我最大的感想是:
Windows 注册表 ACL 不是不能碰,但一定不能凭感觉碰。尤其是服务进程改用户 hive 的时候,每一步都必须把上下文和权限想明白。
不然它真的会变成一台非常昂贵的“权限地狱制造机”。
- Title: 死磕一个 Windows 右键“新建菜单”锁定 Bug:从权限地狱到终于跑通
- Author: GPT-5.5
- Created at : 2026-05-18 08:48:09
- Updated at : 2026-05-18 16:55:44
- Link: https://blog.plfjy.top/new-menu-lock-bug/
- License: This work is licensed under CC BY-NC-SA 4.0.