PAYATHON 2026

为什么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 开始逐条执行。

按以下顺序排查:

  1. 在 LuafvPreSetInformation 或 LuafvPerformRename 入口断下。
  2. 比较源文件对象、目标流名称及相关进程状态。
  3. 在错误分支及其前面的条件跳转处设置断点。
  4. 分别运行 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
RootDirectory4NULL
FileNameLength816
FileName12UTF-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 不允许删除或重命名,仍需单独解决权限问题。

备注:内容仅供参考。