单实例检测有个常见写法:启动时建一个命名 Mutex,后启动的进程发现同名对象已经存在,就知道有实例在跑了。给这个 Mutex 配上一份自己写的权限,再在同一个进程里连着建两次:
PSECURITY_DESCRIPTOR sd = nullptr;
ConvertStringSecurityDescriptorToSecurityDescriptorW(
L"D:(A;;0x100001;;;SY)(A;;0x100001;;;BA)(A;;0x100001;;;IU)",
SDDL_REVISION_1, &sd, nullptr);
SECURITY_ATTRIBUTES sa{sizeof(sa), sd, FALSE};
HANDLE first = CreateMutexW(&sa, FALSE, L"Local\\AclLab.SingleInstance");
HANDLE second = CreateMutexW(&sa, FALSE, L"Local\\AclLab.SingleInstance");
first CreateMutexW: handle=0000000000000120 error=0
second CreateMutexW: handle=0000000000000000 error=5
同一个用户第二次打开自己建的 Mutex,却被拒绝了。错误码 5 是 ERROR_ACCESS_DENIED。换成 OpenMutexW(SYNCHRONIZE, ...) 又能打开;不传安全属性、用默认权限的话,第二次 CreateMutexW 也会成功,错误码是 183(ERROR_ALREADY_EXISTS)。
这背后是一个更一般的问题:进程打开一个对象时,Windows 怎么决定给不给它要的权限?要回答它,得把访问控制完整过一遍:进程带着什么身份,对象上写着什么规则,系统拿两者怎么比,比完的结果存在哪。文中的输出都来自 Windows 11 上一个普通用户的进程,我把自己的用户 SID 换成了 <me>。
进程的身份写在令牌里
每个进程都有一个主令牌(primary access token)。用户登录时,系统为这次登录建一个令牌,之后启动的进程都拿到它的副本,子进程默认继承父进程的令牌。线程还可以临时换上另一个用户的令牌,这叫模拟(impersonation),服务端代表客户端去访问资源时常用。线程在模拟时,访问检查用它的模拟令牌,否则用进程的主令牌。
令牌里和访问检查有关的内容有:用户 SID、用户所属各个组的 SID、特权、完整性级别,以及新建对象时要用到的默认所有者和默认 DACL。
SID(security identifier)是用户和组的编号,比如 S-1-5-21-<机器或域的标识>-1001。S 后面依次是版本号、标识机构和若干子机构,最后一段是相对标识(RID)。系统有一批固定的 well-known SID:Everyone 是 S-1-1-0,LocalSystem 是 S-1-5-18,内置 Administrators 组是 S-1-5-32-544,交互式登录的用户都在 INTERACTIVE(S-1-5-4)里。每次登录还有一个登录会话 SID,形如 S-1-5-5-X-Y,同一次登录启动的进程共享它。访问检查只比 SID,账户改名不影响权限。
whoami /groups 能看到当前令牌里的组(这里只摘了几行,去掉了 Type 列):
Group Name SID Attributes
Mandatory Label\Medium Mandatory Level S-1-16-8192
Everyone S-1-1-0 Mandatory group, Enabled by default, Enabled group
BUILTIN\Users S-1-5-32-545 Mandatory group, Enabled by default, Enabled group
NT AUTHORITY\INTERACTIVE S-1-5-4 Mandatory group, Enabled by default, Enabled group
NT AUTHORITY\Authenticated Users S-1-5-11 Mandatory group, Enabled by default, Enabled group
每个组 SID 都带属性,决定它在访问检查里怎么用。启用(enabled)的 SID 正常参与比对,禁用的被忽略。还有一种 deny-only SID,只拿来匹配拒绝规则,允许规则对它无效。管理员在 UAC 下没提权时,令牌里的 Administrators 组就是 deny-only 的,whoami 会显示 “Group used for deny only”。Mandatory 表示这个组不能被禁用。第一行不是组,是令牌的完整性级别,后面单独讲。
特权(privilege)是另一套东西。它不挂在哪个对象上,管的是系统级操作,比如改系统时间、加载驱动、调试任意进程。whoami /priv 能列出当前令牌有哪些特权、是否启用。大部分特权默认是禁用的,程序要先调 AdjustTokenPrivileges 打开才生效。有几个特权会绕过对象上的权限设置,后面讲访问检查时会说到。
对象上挂着安全描述符
能设置权限的对象叫可保护对象(securable object),文件和目录、注册表键、进程和线程、服务、命名管道、Mutex 和 Event 这类同步对象、文件映射、Job 对象都是。每个可保护对象都带一个安全描述符(security descriptor),里面有四样东西:所有者 SID、主组 SID、DACL(discretionary access control list)和 SACL(system access control list)。
所有者对自己的对象有额外的权限,后面讲访问检查时说。主组是为 POSIX 兼容留的,Windows 自己基本不用。DACL 决定谁能对这个对象做什么。SACL 里放两类东西:审计规则,决定哪些访问要记进安全日志;以及对象的完整性标签。读写审计规则要单独申请 ACCESS_SYSTEM_SECURITY 权限,还得有 SeSecurityPrivilege。完整性标签不受这个限制,读它只要 READ_CONTROL,改它要 WRITE_OWNER。
没有 DACL 和 DACL 为空是两回事。没有 DACL(NULL DACL)时,系统对所有人放行,连改权限、改所有者都放行。DACL 存在但里面一条规则都没有时,除了所有者自带的那点权限,谁都打不开。
ACE 和访问掩码
DACL 是一串 ACE(access control entry),每条 ACE 由类型、标志、访问掩码和一个 SID 组成。DACL 里常用的类型只有两种,允许(access allowed)和拒绝(access denied)。SACL 里放的是审计 ACE 和强制标签(mandatory label)ACE。另外还有 Active Directory 用的对象 ACE,和带条件表达式的 callback ACE,这里不展开。
访问掩码是一个 32 位整数,每一位代表一项权限,所有对象类型都用同一个布局:
| 位 | 内容 |
|---|---|
| 0–15 | 对象特定权限,每种对象自己定义,比如文件的 FILE_READ_DATA |
| 16–23 | 标准权限,现在用到 DELETE、READ_CONTROL、WRITE_DAC、WRITE_OWNER、SYNCHRONIZE 五个 |
| 24 | ACCESS_SYSTEM_SECURITY,读写 SACL |
| 25 | MAXIMUM_ALLOWED |
| 28–31 | 通用权限 GENERIC_ALL、GENERIC_EXECUTE、GENERIC_WRITE、GENERIC_READ |
五个标准权限分别是删除对象、读安全描述符(SACL 除外)、改 DACL、改所有者、等待对象。
通用权限是一种简写。每种对象都有一张映射表(generic mapping),把四个通用位换成自己的标准权限和特定权限。比如文件的 GENERIC_READ 会换成 READ_CONTROL、SYNCHRONIZE、FILE_READ_DATA、FILE_READ_EA 和 FILE_READ_ATTRIBUTES。后面会看到,令牌默认 DACL 里写的 GA(GENERIC_ALL)落到 Mutex 上,就变成了 0x1F0001,也就是 MUTEX_ALL_ACCESS。
MAXIMUM_ALLOWED 用在打开对象时,意思是不指定具体要哪些权限,能给的都给。
把安全描述符写成 SDDL
SDDL(Security Descriptor Definition Language)是安全描述符的字符串格式,分四段:
O:<所有者>G:<主组>D:<DACL 标志>(ACE)(ACE)...S:<SACL 标志>(ACE)...
用不到的段可以省略。DACL 标志常见的有 P(protected,不从父对象继承 ACE)和 AI(这个 DACL 参与了自动继承)。每条 ACE 写在一对括号里,用分号分成六个字段:
ace_type;ace_flags;rights;object_guid;inherit_object_guid;account_sid
类型 A 是允许,D 是拒绝,AU 是审计,ML 是强制标签。标志里 OI、CI、IO、NP、ID 和继承有关,SA、FA 表示审计成功或失败的访问。权限可以写十六进制,也可以写缩写:GA、GR、GW、GX 是通用权限,RC、SD、WD、WO 是标准权限,文件有 FA、FR、FW、FX,注册表有 KA、KR、KW、KX。两个 GUID 字段只有 Active Directory 用。SID 可以写完整的 S-1-...,也可以用两个字母的别名,比如 SY(LocalSystem)、BA(Administrators)、BU(Users)、IU(INTERACTIVE)、WD(Everyone)、CO(CREATOR OWNER)。
回头看开头那行:
D:(A;;0x100001;;;SY)(A;;0x100001;;;BA)(A;;0x100001;;;IU)
它只有 DACL 一段,三条允许 ACE,分别给 SYSTEM、Administrators 和 INTERACTIVE 同样的权限 0x100001,也就是 SYNCHRONIZE(0x00100000)加上 0x0001。0x0001 在 Win32 头文件里叫 MUTEX_MODIFY_STATE,文档说是保留位,但 SDK 里它就是 NT 层 MUTANT_QUERY_STATE 的别名。没写所有者,建对象时系统按创建者来填。
想看真实对象的 SDDL,可以在 PowerShell 里跑 (Get-Acl <路径>).Sddl。icacls 也能查看和修改文件的 DACL,输出格式不同,但内容是同一份。
访问检查怎么做
进程打开对象时,要说明自己要哪些权限,比如 CreateFileW 的 dwDesiredAccess 参数。系统拿令牌和对象的安全描述符,算出这些权限能不能全给:
flowchart TD
Req["请求的权限"] --> MIC["完整性检查"]
MIC --> Priv["特权和所有者"]
Priv --> Dacl["逐条比对 DACL"]
Dacl --> Restricted{"受限令牌?"}
Restricted -->|"是"| Again["按限制 SID 复查"]
Restricted -->|"否"| Result{"全部拿到?"}
Again --> Result
Result -->|"是"| Handle["返回句柄"]
Result -->|"否"| Denied["ERROR_ACCESS_DENIED"]
完整性检查在 DACL 之前做,放到讲 UAC 时一起说。
特权和所有者这一步,处理几种不看 DACL 的情况。请求 ACCESS_SYSTEM_SECURITY 必须有 SeSecurityPrivilege。启用了 SeTakeOwnershipPrivilege 的令牌总能拿到 WRITE_OWNER。所有者总能拿到 READ_CONTROL 和 WRITE_DAC,就算 DACL 里没写。想取消所有者的这份默认权限,可以在 DACL 里加一条给 OWNER RIGHTS(S-1-3-4,别名 OW)的 ACE,这条 ACE 会取代那份默认权限。所有者匹配上的其他 ACE 照样生效。文件还有两个特例:以 FILE_FLAG_BACKUP_SEMANTICS 打开时,SeBackupPrivilege 给所有读权限,SeRestorePrivilege 给所有写权限。
DACL 这一步,按文档的说法,系统从第一条 ACE 开始往后看,直到出现下面三种情况之一:
- 某条拒绝 ACE 的 SID 在令牌里,并且它拒绝了请求中的任意一项权限,直接拒绝;
- 令牌里的 SID 匹配上的若干条允许 ACE,加起来给齐了请求的所有权限,放行;
- 所有 ACE 都看完,还有请求的权限没给到,拒绝。
同一个用户匹配上的多条允许 ACE 会累加。一个组拿到读、用户本人拿到写,这个用户就能读写。反过来,DACL 里一条拒绝 ACE 都没有也能拒人,令牌里的 SID 一条都匹配不上,就走到第 3 种情况。
受限令牌(restricted token)是用 CreateRestrictedToken 做出来的,可以去掉特权、把一些 SID 改成 deny-only,还可以加一组限制 SID(restricting SID)。带限制 SID 的令牌要过两遍检查,一遍用令牌里启用的 SID,一遍只用限制 SID,两遍都通过才放行。
ACE 的顺序会改变结果
因为第一条命中的拒绝 ACE 就会终止检查,一旦请求的权限已经给齐也会终止,ACE 的顺序会影响结果。给一个文件写两条针对同一个 SID 的 ACE,一条拒绝读,一条允许全部,然后只调换顺序,再以读权限打开:
D:PAI(D;;FR;;;<me>)(A;;FA;;;<me>)
deny first: denied (error 5)
D:PAI(A;;FA;;;<me>)(D;;FR;;;<me>)
allow first: opened (error 0)
第二种写法里,第一条允许 ACE 已经给齐了读权限,系统到这里就停了,后面的拒绝 ACE 根本没看到。
Windows 为此规定了一个推荐顺序:显式 ACE 排在继承来的 ACE 前面;显式 ACE 里拒绝在前、允许在后;继承来的按层级排,从父对象继承的在前,祖父的在后,每一层内部也是拒绝在前。资源管理器里的安全编辑器会按这个顺序排。AddAccessAllowedAceEx 这类底层函数只是把 ACE 追加到末尾,ConvertStringSecurityDescriptorToSecurityDescriptorW 也照字符串的顺序来,排序是调用方自己的事。上面那个试验就是这样写出了一个不按推荐顺序排的 DACL。
检查只在打开句柄时做一次
访问检查发生在打开句柄的时候。检查通过后,系统把这次授予的权限记在句柄里,之后通过这个句柄做的每个操作,都只和句柄里记下的权限比,不再回头看安全描述符。
拿一个文件试一下:先以读权限打开,再把它的 DACL 改成 D:P,也就是一条 ACE 都没有、也不继承。然后分别用旧句柄读一次,再开一个新句柄:
ReadFile on old handle after DACL set to D:P: ok, read 5 bytes
new open for read: denied (error 5)
这个设计带来几个后果。收回权限挡不住已经打开的句柄,要真正断开,只能让持有者关掉句柄或者结束进程。句柄可以通过继承或者 DuplicateHandle 交给别的进程,对方直接用句柄里的权限,不会拿它自己的令牌再检查一遍。反过来,句柄里没有的权限事后也用不了,以只读方式打开的文件,WriteFile 会直接失败。所以打开对象时,最好只要用得到的权限。要多了,在 DACL 收得紧的对象上就会被拒,开头的 Mutex 就是这么被拒的。
新对象的安全描述符从哪来
建对象时,文档给的 DACL 规则是:
- 创建者传了安全描述符,就用其中的 DACL,并合并父对象里可继承的 ACE,除非这个 DACL 标了
P; - 没传安全描述符,就用父对象的可继承 ACE 拼出 DACL;
- 没传,父对象也没有可继承的 ACE,就用创建者令牌里的默认 DACL;
- 以上三样都没有,对象就没有 DACL,谁都能完全访问。
第 1 条的合并,我在 NTFS 文件和命名 Mutex 上都没有观察到。父目录带着 (A;OICI;FA;;;<me>)(A;OICI;FR;;;BU),用 CreateFileW 在里面建文件时传一个显式的 D:(A;;FA;;;<me>),结果是 D:P(A;;FA;;;<me>)。描述符上加了 AR(要求自动继承)也一样。父目录的 ACE 没合并进来,DACL 还被标上了 P,以后改父目录的权限也传不到它。想让新文件继承目录的权限,就不要给它传 DACL。
第 2、3 条可以用命名内核对象看得很清楚。不传安全属性,分别在 Local\ 和 Global\ 下建一个 Mutex:
Local\AclLab.NsTest:
O:<me>D:(A;;0x1f0001;;;SY)(A;;0x1f0001;;;<me>)(A;;0x1f0001;;;S-1-5-5-0-1226676)
Global\AclLab.NsTest:
O:<me>D:(A;;0x1f0001;;;<me>)(A;;0x1f0001;;;BA)(A;;0x1f0001;;;SY)
两边的 DACL 不一样。Local\ 对应的对象目录是 \Sessions\1\BaseNamedObjects,它的 DACL 里有三条可继承的 ACE(这里只摘了这三条):
(A;OICIIO;GA;;;SY)(A;OICIIO;GA;;;CO)(A;OICIIO;GA;;;S-1-5-5-0-1226676)
Local\ 下的 Mutex 走的是第 2 条,CO 被换成了创建者,也就是我。Global\ 对应的 \BaseNamedObjects 没有可继承的 ACE,走的是第 3 条,结果和这台机器上令牌的默认 DACL 一致:
D:(A;;GA;;;<me>)(A;;GA;;;BA)(A;;GA;;;SY)
所有者也来自令牌,是令牌里的默认所有者,普通用户的进程就是用户本人。
继承
文件和注册表键主要靠继承管理权限。ACE 的继承标志决定它往下传给谁:OI(object inherit)传给子文件,CI(container inherit)传给子目录,IO(inherit only)表示这条 ACE 只用来继承,对当前对象不生效,NP(no propagate)表示只往下传一层。继承下来的 ACE 带 ID 标志。CO(CREATOR OWNER)是一个占位 SID,继承时会换成新对象的所有者。
给一个目录设这样的 DACL,再在里面建一个文件和一个子目录:
dir: D:PAI(A;;FA;;;<me>)(A;OICIIO;FA;;;CO)(A;OICI;FR;;;BU)
new file: D:AI(A;ID;FA;;;<me>)(A;ID;FR;;;BU)
new dir: D:AI(A;ID;FA;;;<me>)(A;OICIIOID;FA;;;CO)(A;OICIID;FR;;;BU)
第一条没有继承标志,只管目录自己,所以没传下去。CO 那条到了文件上换成了 <me>;到了子目录上变成两条,一条换成 <me> 管子目录自己,另一条保留 CO 和 IO,继续往下传。BU 那条在子目录上保留了 OICI,孙辈也会继承到。
目录的 DACL 标了 P,所以它不会从自己的父目录继承任何 ACE。用资源管理器、icacls 或者 SetNamedSecurityInfoW 改一个目录的权限时,工具会把可继承的变化推到下面所有没标 P 的子孙上。这次传播发生在改权限的那一刻,之后访问检查只看每个对象自己身上的 DACL。
UAC 和完整性级别
管理员账户登录时,系统会建两个令牌。平时用的是过滤过的那个,Administrators 组被标成 deny-only,管理类特权被拿掉,完整性级别是中。点了 UAC 提示提权之后,进程拿到的是完整的令牌,完整性级别是高。普通用户只有一个令牌,完整性级别也是中,前面 whoami /groups 的第一行就是 Medium Mandatory Level。
完整性级别是 DACL 之外的另一道检查,叫强制完整性控制(Mandatory Integrity Control),在比对 DACL 之前进行。令牌带一个级别,对象的 SACL 里带一条强制标签 ACE,级别用 SID 表示:低是 S-1-16-4096,中是 S-1-16-8192,高是 S-1-16-12288,系统是 S-1-16-16384,SDDL 别名分别是 LW、ME、HI、SI。标签 ACE 的权限位写的是策略:NW(no write up)、NR(no read up)、NX(no execute up),系统建对象时默认只有 NW。低级别的进程访问高级别的对象时,策略禁止的那类权限直接拿不到,DACL 允许也没用。没有标签的对象按中级别算。
前面那两个 BaseNamedObjects 目录的 SACL 里都有这样一条:
S:(ML;;NW;;;LW)
它把目录标成了低级别,所以低完整性的进程也能在里面建命名对象。
新进程的完整性级别取用户级别和可执行文件标签中较低的那个,所以管理员运行一个标成低级别的程序,进程也只是低级别。浏览器沙箱就是把这些机制叠在一起用的,Chromium 在 Windows 上给渲染进程用受限令牌、Job 对象和很低的完整性级别。
回到那个 Mutex
现在可以解释开头的现象了。完整的输出是这样的:
first CreateMutexW: handle=0000000000000120 error=0
its SD: O:<me>D:(A;;0x100001;;;SY)(A;;0x100001;;;BA)(A;;0x100001;;;IU)
second CreateMutexW: handle=0000000000000000 error=5
OpenMutexW(SYNCHRONIZE): handle=0000000000000148 error=0
CreateMutexExW(SYNCHRONIZE): handle=000000000000014C error=183
OpenMutexW(READ_CONTROL|WRITE_DAC) as owner: handle=0000000000000150 error=0
名字已经存在时,CreateMutexW 会去打开已有的对象。文档写明,这时它请求的是 MUTEX_ALL_ACCESS,也就是 0x1F0001,传进去的安全描述符会被忽略。我的令牌里有 INTERACTIVE,匹配第三条 ACE,拿到 0x100001;我又是所有者,再加上 READ_CONTROL 和 WRITE_DAC。加起来还差 DELETE 和 WRITE_OWNER,DACL 看完也凑不齐,于是被拒。
OpenMutexW 只要 SYNCHRONIZE,第三条 ACE 就给齐了。CreateMutexExW 多一个 dwDesiredAccess 参数,打开已有对象时按它请求权限,所以能直接拿到句柄,错误码 183 表示对象已经存在。最后一行是所有者自带的那两项权限在起作用。
不传安全属性时,这个 Mutex 用的是从会话目录继承来的 DACL,我自己有 0x1F0001,第二次 CreateMutexW 也就能成功。开头的怪现象,是自己写的 DACL 比 CreateMutexW 固定请求的权限小造成的。写单实例检测时,改用 CreateMutexExW 只要 SYNCHRONIZE,就不会撞上这个问题。
UAC 下没提权的管理员,用 OpenMutexW 或 CreateMutexExW 只要 SYNCHRONIZE 也能打开这个 Mutex。它令牌里的 Administrators 是 deny-only,匹配不上第二条 ACE,但它还有 INTERACTIVE。
这套机制挡不住的
DACL 管的是没有特殊特权的普通进程。持有 SeTakeOwnershipPrivilege 的进程能把所有者改成自己,所有者又自带 WRITE_DAC,接着就能把 DACL 改成任何样子。SeBackupPrivilege 和 SeRestorePrivilege 能绕过文件的 DACL 读写,SeDebugPrivilege 能无视 DACL 打开进程,受保护进程(PPL)除外。提权后的管理员和 SYSTEM 默认都持有这些特权,需要时启用就行,所以不要指望靠 DACL 防住管理员。
DACL 保护的是对象,保护不了名字。命名对象谁先建,DACL 就由谁定。别人抢先用同一个名字建一个,你的程序要么打开的是别人的对象,要么被拒。
已经打开的句柄不受后来权限修改的影响,前面试过了。
NULL DACL 等于不设防。为了“让所有人都能访问”而传一个 NULL DACL,比写一条给 Everyone 读权限的 ACE 危险得多,因为它连 WRITE_DAC 和 WRITE_OWNER 都放出去了,任何人都能接管这个对象。