为什么32位 Go 重命名 NTFS ADS 需要提权,而 C++ 不需要?
结论
32 位 Go 程序重命名 NTFS ADS,没有额外的管理员权限要求。调试结果已将失败位置定位到 luafv.sys 的重命名处理路径,应先检查两个进程的 UAC 文件虚拟化状态和应用程序 manifest。
目前最符合现象的解释是:Go 进程启用了 UAC 虚拟化,ADS 重命名在进入 luafv!LuafvPerformRename 后被拒绝;C++ 进程的虚拟化状态不同,没有触发同一失败分支。提权后,UAC 虚拟化不再适用,因此可能绕过这个问题。
但两个进程的令牌信息和 manifest 尚未给出,还不能确认根因就是“Go 缺少 manifest、C++ 自带 manifest”。需要在不提权的情况下做一次对照测试。
为什么 API 参数相同,结果仍然不同?
SetFileInformationByHandle 的显式参数只决定了部分行为。Windows 还会检查调用进程的访问令牌、兼容性设置,以及句柄实际关联的文件对象。
UAC 文件虚拟化是为旧应用提供的兼容机制,luafv.sys 负责文件系统一侧的处理。它与虚拟机、Go 的 goroutine 栈、WOW64 文件系统重定向是不同的机制。Windows Internals 的相关说明
与本例直接相关的规则有两条:
- UAC 虚拟化只支持 32 位应用,已提权的进程不适用。
- 应用 manifest 中声明了
requestedExecutionLevel,就会禁用文件和注册表虚拟化。
因此,遇到“32 位时失败、提权后成功”的情况,应先检查虚拟化状态。Microsoft UAC 架构文档
C++ 工具链可能在链接时加入默认 manifest。MSYS2 提供了 windows-default-manifest 包,但测试程序是否使用了它,需要检查生成的 .exe,仅凭编译命令无法判断。MSYS2 默认 manifest 包
ERROR_INVALID_NAME 也不一定来自 NTFS 的名称检查。给出的跟踪显示,STATUS_OBJECT_NAME_INVALID 在 LUAFV 路径中产生,随后向上传播,不能简单理解为“缺少管理员权限”。
先核对虚拟化状态,再修改程序
1. 查询进程令牌
分别在两个未提权进程中查询:
TokenVirtualizationAllowed:令牌是否允许虚拟化。TokenVirtualizationEnabled:令牌是否已经启用虚拟化。
判断当前行为时,应重点看 TokenVirtualizationEnabled,不要将“允许”与“已启用”混为一谈。这两个信息类返回的都是 DWORD。TOKEN_INFORMATION_CLASS 文档
Go 可以在创建测试文件前调用下面的函数:
func virtualizationEnabled() (bool, error) {
var token windows.Token
if err := windows.OpenProcessToken(
windows.CurrentProcess(),
windows.TOKEN_QUERY,
&token,
); err != nil {
return false, err
}
defer token.Close()
var enabled uint32
var returnedLen uint32
err := windows.GetTokenInformation(
token,
windows.TokenVirtualizationEnabled,
(*byte)(unsafe.Pointer(&enabled)),
uint32(unsafe.Sizeof(enabled)),
&returnedLen,
)
if err != nil {
return false, err
}
return enabled != 0, nil
}
函数使用示例中已有的 windows 和 unsafe 导入。在 main 开头记录结果:
enabled, err := virtualizationEnabled()
if err != nil {
log.Fatal(err)
}
log.Printf("TokenVirtualizationEnabled=%v", enabled)
C++ 侧用 OpenProcessToken 和 GetTokenInformation 查询同一信息类,再比较结果。
2. 提取两个程序的 manifest
安装 Windows SDK 后,可以用其中的 manifest 工具 mt.exe 检查常见的嵌入式应用 manifest。在 Windows 命令提示符中执行:
mt.exe "-inputresource:renameads_go.exe;#1" -out:go.manifest
mt.exe "-inputresource:renameads_cpp.exe;#1" -out:cpp.manifest
重点查找:
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
如果提取失败,需确认程序是否缺少该资源,并检查程序旁边是否有外部 .manifest 文件。mt.exe 文档
3. 为 Go 程序声明 asInvoker
如果确认 Go 程序没有执行级别声明,可以创建 app.manifest:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1"
manifestVersion="1.0">
<assemblyIdentity
type="win32"
name="Example.RenameADS"
version="1.0.0.0"
processorArchitecture="x86" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel
level="asInvoker"
uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
在 cmd.exe 中重新构建并嵌入:
set GOARCH=386
go build -o renameads_go.exe .
mt.exe -manifest app.manifest "-outputresource:renameads_go.exe;#1"
asInvoker 表示沿用启动者的访问令牌。从普通终端启动时,程序不会主动请求提权;其中的 requestedExecutionLevel 声明也会禁用 UAC 文件虚拟化。应用程序 manifest 文档
如果程序已有 manifest,应合并这段声明,并保留原有配置。每次 go build 都会重新生成可执行文件,需要把资源嵌入步骤纳入构建流程。如果需要数字签名,应在资源修改完成后签名。
4. 做一次受控复测
复测时保持以下条件一致:
- 从同一个未提权终端启动。
- 使用用户有写入和删除权限的 NTFS 测试目录,并使用绝对路径。
- 每轮恢复源 ADS,清除上一轮留下的目标 ADS。
- 记录修改前后的虚拟化状态和重命名结果。
如果只加入 asInvoker 声明,确认虚拟化关闭后,原来的调用就成功了,便有依据将故障与虚拟化处理路径联系起来。若仍然失败,就继续检查实际文件对象、兼容性设置和过滤驱动分支,此时还不能认定 manifest 已解决问题。
两份调用栈为什么看起来不同?
调用栈只反映暂停时的执行现场,无法展示完整的调用历史。Filter Manager 可以依次调用多个 minifilter 的回调。前一个回调返回后,它的栈帧就会消失,在后一个回调中暂停时便看不到它。过滤器也可以在预操作回调中直接完成请求,设置最终状态。minifilter 预操作回调文档
因此,仅凭调用栈快照,无法得出“Go 只经过 LUAFV,C++ 只经过 WdFilter”的结论。C++ 的栈里也出现了:
luafv!LuafvGenerateFileName
这说明 LUAFV 参与了那次名称查询,但不能证明它执行过与失败案例相同的重命名分支。
现有证据也不支持将问题归因于 Go 的栈管理。既然系统调用参数已确认一致,跟踪也显示 LUAFV 主动生成了错误状态,下一步应比较驱动分支所依赖的进程状态。
怎样比逐条执行更快地定位错误?
用 Process Monitor 比较操作
先按进程和测试路径过滤,检查文件打开及重命名事件的路径、结果、参数和 Stack。尤其要确认两个程序是否操作同一位置的文件,以及是否出现 VirtualStore 路径。
Process Monitor 支持文件操作过滤和事件调用栈,可以用来缩小排查范围。Process Monitor 文档
在关键分支设置断点
找到设置 0xC0000033 的指令后,可以直接检查哪个条件跳到了这里,无需从 NtSetInformationFile 开始逐条执行。
按以下顺序排查:
- 在
LuafvPreSetInformation或LuafvPerformRename入口断下。 - 比较源文件对象、目标流名称及相关进程状态。
- 在错误分支及其前面的条件跳转处设置断点。
- 分别运行 Go、C++ 和加入 manifest 后的 Go 程序,记录分支差异。
内核调试中可以用进程限定断点减少无关命中:
bp /p <EPROCESS地址> luafv!LuafvPreSetInformation
/p 后面需要的是 EPROCESS 地址,不是 PID。WinDbg 断点文档
用 uf 查看函数控制流,在不关心的函数调用处用 p,需要运行到当前函数返回时用 gu。如果请求被转交给工作线程,进程限定断点可能漏掉后续处理,需要结合线程和请求对象继续跟踪。
minifilter 预操作回调返回的 FLT_PREOP_CALLBACK_STATUS 与请求的 IoStatus.Status 含义不同,检查回调时应同时观察两者。
题目中的 +0x6683 和指令地址只适用于当时加载的驱动构建,不能直接用于另一台机器或另一个补丁版本。
缓冲区还需要注意什么?
给出的 x86 缓冲区看不出明显的结构布局错误:
| 字段 | x86 偏移 | 示例值 |
|---|---|---|
ReplaceIfExists 及其后的空间 | 0 | 首字节为 1 |
RootDirectory | 4 | NULL |
FileNameLength | 8 | 16 |
FileName | 12 | UTF-16 的 :testads |
:testads 包含 8 个 UTF-16 代码单元,名称长度为 16 字节;加上 12 字节头部和 2 字节终止符,总计 30 字节。FILE_RENAME_INFO 允许目标名称使用以 : 开头的 NTFS 流名,FileNameLength 按字节计算,不包含终止符。FILE_RENAME_INFO 文档
还需留意以下细节:
FileRenameInfo使用的ReplaceIfExists是BOOLEAN。示例写入uint32(1)后,得到的字节恰好符合当前布局,但这不代表字段类型是DWORD。- 切换到 x64 后,句柄宽度和字段偏移会变化,不能继续硬编码 x86 的偏移。
- C++ 分配变长结构时,用
offsetof(FILE_RENAME_INFO, FileName)表达名称起点,比从sizeof(FILE_RENAME_INFO)中减去一个WCHAR更准确,后者可能包含尾部填充。 - 相同的相对路径和
CreateFileW参数,不足以证明最终打开了同一个文件。 - 关闭虚拟化不会增加访问权限。如果目标文件的 ACL 不允许删除或重命名,仍需单独解决权限问题。
备注:内容仅供参考。
