带有管理员组的令牌,不等于线程已经获得管理员访问能力。2023 年的一组 Windows 记录同时出现“创建成功”“模拟成功”和文件访问错误 1346,差异恰好落在令牌对象、线程有效身份与对象访问检查之间。
这里把三者分开观察:先确认调用者已有的 SeCreateTokenPrivilege,再检查新令牌的各项属性,最后验证真正执行文件操作的线程。记录涉及 10.0.17134.1 与 10.0.20348.587;其他静态记录缺少完整构建信息,不将它们归并成对所有 Windows 版本的结论。
起点是已有特权,不是普通账户
旧构建的起始输出包含:
$ whoami /priv
Privilege Name Description State
SeCreateTokenPrivilege Create a token object Disabled
SeChangeNotifyPrivilege Bypass traverse checking EnabledDisabled 表示该项已经在令牌中,只是尚未启用。AdjustTokenPrivileges 调整已有特权,未列入令牌的特权不会因为调用这个函数就出现;即使函数返回非零,也要检查 GetLastError() 是否为 ERROR_NOT_ALL_ASSIGNED。这是 API 的明确约定,而不是一般意义上的“调用成功”。Microsoft API 文档
还要确定调整的是执行受检操作的令牌。向待创建或待使用的令牌写入某项特权,与调用者在创建操作时具备该特权,是两个问题。窗口标题中的 Administrator 也不足以还原账户配置、令牌来源和提升方式。
把令牌属性放在不同坐标轴上
样本使用 ZwCreateToken 请求模拟令牌(impersonation token),保留当前用户身份,加入内置管理员组,并设置中等完整性标签。相关输出的字段应分别解释:
| 字段 | 记录中的值或对象 | 分析意义 |
|---|---|---|
TokenUser |
当前用户 SID | 令牌代表的用户,不是默认所有者 |
TokenGroups |
S-1-5-32-544 等 |
组 SID 还要结合 enabled、deny-only 等属性 |
TokenPrivileges |
6 项 | 包含与启用是两个状态 |
TokenIntegrityLevel |
S-1-16-8192 |
中等完整性,不等价于“普通用户全部权限” |
TokenType |
2 |
TokenImpersonation |
TokenImpersonationLevel |
2 |
SecurityImpersonation,本地模拟级别 |
AuthenticationId |
0x3e7 或 0x3e6 |
登录会话 LUID,不是用户 SID |
TokenOwner |
默认所有者 SID | 不应替代 TokenUser 判断身份 |
TokenType = 2 与 TokenImpersonationLevel = 2 来自不同枚举。SecurityIdentification 允许识别客户端,却不提供相同的本地模拟能力;SecurityDelegation 又涉及远程系统的模拟。访问令牌和模拟级别的定义分别见 Access tokens 与 SECURITY_IMPERSONATION_LEVEL。
中等完整性与管理员组同时出现,并不矛盾。完整性策略、组属性、特权和对象 DACL 共同影响具体操作;令牌自身的默认 DACL 则主要服务于新对象的安全描述符,不是对所有已有对象的通行证。
错误 1346 出现在源文件打开阶段
成功记录报告读取并写入 8704 字节。文件列表中的大小为 8.50 KB,与 8704 / 1024 = 8.5 一致;下方树只保留相关文件:
- C:\Windows\System32
- malicious.dllsize 8704
另一次记录先输出创建令牌和模拟成功,接着出现:
[*] Successfully impersonated the elevated token.
[-] Could not open source file by CreateFileW: [1346].
[-] Failed to exploit SeCreateTokenPrivilege.这里失败的是 source file,还没有到目标文件写入。1346 = 0x542 对应 ERROR_BAD_IMPERSONATION_LEVEL,含义与所需模拟级别缺失或无效有关,不是一般的 DACL 拒绝,也不是 ZwCreateToken 的返回状态。Microsoft 错误码
SetThreadToken 成功后,应重新通过 OpenThreadToken 获取该线程的模拟令牌,再查询类型、级别、组属性、特权与完整性。OpenAsSelf = TRUE 只是让“打开令牌句柄”这次访问检查使用进程安全上下文,并非把线程改回进程身份,也不会自动提升返回令牌的模拟级别。OpenThreadToken 文档
会话标识变化需要独立对照
| 记录 | AuthenticationId | 已打印模拟级别 | 文件结果 | 环境范围 |
|---|---|---|---|---|
| 早期成功 | 0x3e7 |
2 |
读取、写入各 8704 字节 |
与 10.0.17134.1 起始记录配套 |
| 失败对照 | 0x3e7 |
2 |
源文件打开返回 1346 |
静态记录没有完整构建 |
| 调整后成功 | 0x3e6 |
2 |
读取、写入各 8704 字节 |
静态记录没有完整构建 |
0x3e7 与 0x3e6 分别是系统和匿名登录会话常量的低位值,高位为零。修改 AuthenticationId 并不等于把 TokenUser 改成系统或匿名用户,更不等于将模拟级别改为 SecurityAnonymous。
0x3e6 记录中的访问成功值得关注,但它没有展示所有内核检查。要证明因果关系,应固定用户 SID、组及其属性、特权、完整性、模拟级别、对象和访问掩码,仅改变会话标识;成功与失败两边都保存附加前后的有效令牌。现有证据支持记录间的结果差异,不支持“全部检查被跳过”的结论。
动态演示单独显示 10.0.20348.587、组数量 13 和 AuthenticationId = 0x3e6;静态成功记录的组数量为 12。它们不是字段完全相同的同一次运行,版本与配置标签应分别保留。
SID 和变长数组先检查容量
令牌构造器容易把逻辑问题与缓冲区问题混在一起。下面的检查不创建令牌,只验证完整 SID 的区分和可变长数组的尺寸:
def required_bytes(count, first, stride, maximum):
if first < 0 or stride <= 0 or first > maximum:
raise ValueError("invalid layout")
if count < 0 or count > (maximum - first) // stride:
raise ValueError("invalid count")
return first + count * stride
builtin_users = (1, 5, 32, 545)
unrelated_sid = (1, 5, 21, 111, 222, 333, 545)
assert builtin_users[-1] == unrelated_sid[-1]
assert builtin_users != unrelated_sid
assert required_bytes(6, 4, 12, 0xffffffff) == 76元组是概念模型,不是 SID 的内存编码。Windows 的 EqualSid 比较完整且有效的 SID;以系统接口转换并比较这两个示例,得到不相等,长度分别为 16 与 28 字节。将一个更长 SID 原地覆盖到较短 SID 的存储中,会破坏后续数据。
本机只读验证还检查了 TOKEN_PRIVILEGES.Privileges 偏移为 4、LUID_AND_ATTRIBUTES 大小为 12,六项总长为 76。模型通过 1025 个普通数量、一个上界和两个拒绝用例;真实 C/C++ 实现应使用目标 ABI 的 offsetof、sizeof 与容量上界,不把常量当作跨结构保证。
ANYSIZE_ARRAY 为 1 并不意味着只有一项,也不自动为新增项分配空间。TOKEN_PRIVILEGES 文档明确要求为额外项分配足够内存。还应审计分配后覆盖指针、遗漏释放、短读短写,以及 NTSTATUS 与 Win32 错误码混用;这些属于构造器自身的正确性问题。
写入文件之后还有一条加载链
StorSvc 的三段反编译展示了 RPC 入口、工作对象创建与回调中的模块加载。按证据画出的流程如下,虚线处表示所展示片段没有包含工作提交位置:
flowchart TD accTitle: StorSvc 的入口、工作对象与回调 accDescr: 入口经过设备与能力检查后进入初始化,创建工作对象;提交位置在已展示片段中缺失,回调另行执行模块加载。 A["SvcRebootToFlashingMode"] --> B["设备与能力检查"] B --> C["InitResetPhone"] C --> D["CreateThreadpoolWork"] D -. "提交位置未展示" .-> E["ResetPhoneWorkerCallback"] E --> F["LoadLibraryW"] F --> G["GetProcAddress"]
CreateThreadpoolWork 创建工作对象;将其提交到线程池是 SubmitThreadpoolWork 的职责。看到回调地址被注册,不等于该回调已经执行。初始入口还包含设备族、多会话配置和客户端能力相关分支;未初始化和已初始化两条路径也应分开。
回调里的关键顺序可压缩为以下语义摘录,省略等待、锁与后续关机分支:
HMODULE module = LoadLibraryW(L"SprintCSP.dll");
if (module) {
auto factory_reset = reinterpret_cast<void (*)()>(
GetProcAddress(module, "FactoryResetUICC"));
if (factory_reset) {
factory_reset();
}
FreeLibrary(module);
}必须区分首次载入 DLL 的初始化执行与导出函数调用。对于新加载的 DLL,LoadLibraryW 会触发加载初始化;GetProcAddress 找不到指定导出,并不撤销已经发生的初始化。已加载模块、搜索策略、位数、签名或加载策略以及服务有效令牌,也会改变结果。LoadLibraryW 文档
10.0.20348.587 演示最终显示 whoami 为 nt authority\system,同时展示了文件写入与 RPC 客户端提示。这是该环境的结果证据,不是所有版本通用的执行保证;没有对应服务二进制、完整令牌快照和运行跟踪,就应保留加载路径中的未知项。
每个阶段留下独立检查点
- 1
调用者
记录系统构建、起始令牌来源,以及特权存在、启用请求和启用后状态。将 API 返回值与其错误通道一并保留。
- 2
新令牌
分别记录用户、组及属性、特权、完整性、类型、模拟级别和 AuthenticationId。不要用一个字段代替整体安全上下文。
- 3
线程有效令牌
附加后重新查询线程,再执行单一对象访问。分别记录源文件打开、目标创建、读取与写入阶段。
- 4
下游服务
独立验证 RPC 可达性、条件分支、工作提交、实际加载路径和执行身份,避免把文件存在当作执行发生。
- 5
结束与清理
恢复线程身份并检查恢复结果,关闭句柄并核对文件长度。恢复身份失败时应终止该操作路径,避免后续任务继承残留上下文。
结论落在具体访问检查上:已有创建令牌特权、新令牌字段、线程模拟状态和服务加载条件,需要分别证明。本机验证仅覆盖 SID 与尺寸模型,没有创建特权令牌、写入系统目录或触发 StorSvc;历史环境的完整补丁集合与内核原因仍待单独核实。