返回

Go获取Windows句柄:详解0xc0000374崩溃与正确方法

windows

Go 与 Windows 句柄:使用 sys/windows 正确获取系统句柄信息

使用 Go 语言的 sys/windows 包跟 Windows 底层 API 打交道,确实能挖到不少有意思的东西。但有时候,事情并不像想象中那么顺利。比如,尝试用 NtQuerySystemInformation 获取系统句柄信息时,你可能会遇到程序莫名其妙崩溃,退出码是 0xc0000374。这究竟是咋回事?又该怎么正确地拿到我们想要的句柄数据,特别是句柄对应的内核对象地址(KernelAddress)呢?

咱们直接看问题。你可能写了类似下面的代码来分配内存:

var handleInfoSize uint32 = 0x10000 // 初始大小,比如 64KB
handleInfo, err := windows.LocalAlloc(windows.LPTR, uintptr(handleInfoSize))
// 错误处理...

然后调用 NtQuerySystemInformation

var retlength uint32
status := windows.NtQuerySystemInformation(
    windows.SystemExtendedHandleInformation, // 注意这里用的是 Extended
    handleInfo,
    handleInfoSize,
    &retlength,
)

看起来没毛病。获取句柄总数好像也行:

// 假设你的结构体定义如下 (这是有问题的,后面会说)
type SystemHandleTableEntryInfo struct {
    UniqueProcessId      uint16
    CreatorBackTraceIndex uint16
    ObjectTypeIndex      uint8
    HandleAttributes     uint8
    HandleValue          uint16
    Object               unsafe.Pointer // 内核对象地址
    GrantedAccess        uint32
}

type SystemHandleInformation struct {
    NumberOfHandles uint32
    Handles         []SystemHandleTableEntryInfo // 问题根源之一
}

// 尝试读取数量
numHandles := (*SystemHandleInformation)(handleInfo).NumberOfHandles

但是,当你尝试遍历 Handles 数组,比如这样:

// !!! 错误的访问方式 !!!
handle := (*SystemHandleInformation)(handleInfo).Handles[i]

砰!程序很可能就挂了,留下一个 exit status 0xc0000374

另外,怎么判断 NtQuerySystemInformation 调用是否真的成功?那个模仿 C/C++ NT_SUCCESS 宏的 Go 函数:

func NtSuccess(status error) bool {
    // 这种比较方式大概率是不对的
    ntStatus := windows.NTStatus.Errno(windows.STATUS_SUCCESS).Is(status) || windows.NTStatus.Errno(windows.STATUS_PENDING).Is(status)
    fmt.Printf("[*] Succesfully Created Handle! Status: %t\n", ntStatus)
    return ntStatus
}

感觉也不太对劲。windows.NTStatus 是个 uint32 类型,直接跟 Go 的 error 接口用 Is 比对,逻辑上行不通。

别急,我们一步步拆解,看看问题出在哪,然后给出正确的搞法。

为啥程序崩了?剖析 0xc0000374 错误

0xc0000374 这个错误码,通常对应 STATUS_HEAP_CORRUPTION,意思是堆内存被破坏了。这在 C/C++ 里是家常便饭,通常由缓冲区溢出、踩坏内存、或者 free 了一个无效指针引起。在 Go 里,尤其是用了 unsafe 包直接操作内存时,也容易遇到类似的问题。

对照我们的场景,最可疑的地方有两个:

  1. 内存访问越界 :我们分配了一块内存 handleInfo,然后试图把它强转成 SystemHandleInformation 结构体去访问。如果 Go 的结构体定义跟 Windows API 实际返回的数据布局对不上,或者我们试图访问的 Handles[i] 超过了实际分配或返回的数据范围,就可能写坏或读坏不该动的地方,导致堆损坏。
  2. Go 结构体与 C 结构体的映射问题 :这是核心。你在 SystemHandleInformation 里定义 Handles[]SystemHandleTableEntryInfo。Go 里的 slice 不是 C 里的数组。一个 slice 包含三个部分:指向底层数组的指针、长度(len)、容量(cap)。当你把一块原始内存 handleInfo 直接转成 *SystemHandleInformation 时,Handles 这个字段对应的内存区域会被 Go 错误地解释为一个 slice header。这块内存原本应该是第一个 SystemHandleTableEntryInfo 结构体的起始位置,结果被当成了指针、长度和容量。后续对 Handles[i] 的访问自然就全乱套了,绝对会导致内存访问错误。

关键点:NtQuerySystemInformationSystemExtendedHandleInformation

NtQuerySystemInformation 是个强大的 Windows Native API,能获取各种系统信息。使用它时,必须指定 SYSTEM_INFORMATION_CLASS,告诉它你想查啥。

  • SystemExtendedHandleInformation (值为 64):这个信息类返回的是一个 SYSTEM_HANDLE_INFORMATION_EX 结构,里面包含了更详细的句柄信息,特别是包含了64位的对象地址,这在64位系统上获取内核对象地址时非常关键。
  • 与之对应,还有一个 SystemHandleInformation (值为 16),返回的是旧版的 SYSTEM_HANDLE_INFORMATION 结构,里面的句柄条目是 SYSTEM_HANDLE_TABLE_ENTRY_INFO,其 Object 字段大小可能不足以在64位系统上存储完整的内核地址。

既然你的目标是 Handle->ObjectHandleValue(也就是内核地址),并且你用了 SystemExtendedHandleInformation,那么我们就必须按照 SYSTEM_HANDLE_INFORMATION_EX 的布局来处理返回的数据。

还有一个调用 NtQuerySystemInformation 的经典模式:两次调用法 。因为你通常不知道需要多大的缓冲区才能装下所有信息,所以:

  1. 第一次调用,给一个较小(甚至为0)的缓冲区大小,或者一个 nil 的缓冲区指针。如果缓冲区不够大,函数会返回 STATUS_INFO_LENGTH_MISMATCH (通常是 0xC0000004),同时通过 retlength 参数告诉你实际需要的大小。
  2. 第二次调用,根据第一次调用返回的 retlength,分配足够大的内存,然后再次调用函数获取数据。这次应该能成功返回 STATUS_SUCCESS (值为 0)。

动手解决:正确的实现方法

好了,理论分析完毕,上干货。

第一步:定义正确的结构体

既然用了 SystemExtendedHandleInformation,我们就需要定义对应的 Go 结构体。注意,C 语言里的 SYSTEM_HANDLE_INFORMATION_EX 结构大致是这样的:

typedef struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX {
    PVOID Object; // 对象指针 (内核地址)
    ULONG_PTR UniqueProcessId; // 进程ID (用 ULONG_PTR 保证 32/64位兼容)
    ULONG_PTR HandleValue; // 句柄值 (同上)
    ULONG GrantedAccess;
    USHORT CreatorBackTraceIndex;
    USHORT ObjectTypeIndex;
    ULONG HandleAttributes;
    ULONG Reserved; // 有个保留字段!
} SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX, *PSYSTEM_HANDLE_TABLE_ENTRY_INFO_EX;

typedef struct _SYSTEM_HANDLE_INFORMATION_EX {
    ULONG_PTR NumberOfHandles; // 句柄数量 (用 ULONG_PTR)
    ULONG_PTR Reserved; // 另一个保留字段!
    SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX Handles[1]; // 著名的 "flexible array member"
} SYSTEM_HANDLE_INFORMATION_EX, *PSYSTEM_HANDLE_INFORMATION_EX;

转换成 Go 结构体,关键点在于 Handles 字段的处理。它在 C 里是个“柔性数组”(flexible array member),表示结构体后面紧跟着连续的 SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX 数据。Go 里没有直接对应物,我们通常只定义结构体本身,然后通过指针运算访问后面的数组元素。

package main

import "unsafe"

// 注意:字段大小和对齐可能需要根据具体平台调整,
// uintptr 用于指针和句柄值,保证 32/64 位兼容性。
type SystemHandleTableEntryInfoEx struct {
	Object                uintptr // 内核对象地址
	UniqueProcessId       uintptr // 进程 ID
	HandleValue           uintptr // 句柄值
	GrantedAccess         uint32
	CreatorBackTraceIndex uint16
	ObjectTypeIndex       uint16  // 类型索引 (USHORT)
	HandleAttributes      uint32
	Reserved              uint32  // 这个 Reserved 不能漏!
}

// 这个结构体只用来方便地读取 NumberOfHandles
// 实际的 Handles 数据紧随其后,不能直接用 Go slice 访问
type SystemHandleInformationEx struct {
	NumberOfHandles uintptr
	Reserved        uintptr
	// Handles[1]  // Go 里不这么写,这里只是注释说明 C 的结构
}

// 计算单个句柄条目的大小,用于后续指针运算
var systemHandleTableEntryInfoExSize = unsafe.Sizeof(SystemHandleTableEntryInfoEx{})

看到没?SystemHandleInformationEx 我们只定义到 Reserved,不包含 Handles 这个 slice 字段。SystemHandleTableEntryInfoEx 定义要和 C 结构精确匹配,包括那个 Reserved 字段。

第二步:采用两步调用获取数据

现在,我们用正确的姿势调用 NtQuerySystemInformation

package main

import (
	"fmt"
	"os"
	"unsafe"

	"golang.org/x/sys/windows"
)

// ... (结构体定义如上) ...

func main() {
	var status windows.NTStatus
	var requiredLength uint32

	// 第一次调用:获取需要的缓冲区大小
	// 可以给一个 0 大小的缓冲区指针,或者像这样传 nil
	status = windows.NtQuerySystemInformation(
		windows.SystemExtendedHandleInformation,
		nil, // 缓冲区指针给 nil
		0,   // 缓冲区大小给 0
		&requiredLength,
	)

	// 期望返回 STATUS_INFO_LENGTH_MISMATCH (0xC0000004)
	// 注意:直接比较 NTStatus 值,不是 error 类型
	if status != windows.STATUS_INFO_LENGTH_MISMATCH {
		fmt.Printf("第一次调用 NtQuerySystemInformation 失败,状态码: 0x%X\n", status)
		os.Exit(1)
	}

	// 如果 requiredLength 是 0,说明没获取到信息,可能需要提权
	if requiredLength == 0 {
		fmt.Println("获取到的所需缓冲区大小为 0,可能是权限不足或系统无句柄信息")
		os.Exit(1)
	}

    // 稍微增加一点缓冲区大小,以防万一在两次调用之间句柄数量增加
    requiredLength += 1024 * 16 // 增加16KB作为冗余,根据需要调整

	// 分配足够大的内存
	handleInfoPtr, err := windows.LocalAlloc(windows.LPTR, uintptr(requiredLength))
	if err != nil {
		fmt.Printf("LocalAlloc 分配内存失败: %v\n", err)
		os.Exit(1)
	}
	// 别忘了释放!
	defer windows.LocalFree(handleInfoPtr)

	// 第二次调用:获取实际数据
	status = windows.NtQuerySystemInformation(
		windows.SystemExtendedHandleInformation,
		handleInfoPtr,
		requiredLength,
		&requiredLength, // 这个参数这次可以不关心,但还是得传
	)

	// 检查这次调用是否成功 (STATUS_SUCCESS == 0)
	if status != windows.STATUS_SUCCESS {
		fmt.Printf("第二次调用 NtQuerySystemInformation 失败,状态码: 0x%X\n", status)
		os.Exit(1)
	}

	// 到这里,数据应该在 handleInfoPtr 指向的内存里了
	fmt.Println("成功获取句柄信息!")

	// ... 接下来的步骤:解析句柄列表 ...
	parseHandles(handleInfoPtr)
}

func parseHandles(handleInfoPtr windows.Handle) {
    // ... 实现见下一步 ...
}

注意那个 defer windows.LocalFree(handleInfoPtr),非常重要,防止内存泄漏。

第三步:正确解析句柄列表

数据拿到了,怎么从 handleInfoPtr 里把每个句柄信息揪出来?得用指针运算。

// 接上一步的 main 函数结尾,调用 parseHandles

func parseHandles(handleInfoPtr windows.Handle) {
	// 将裸指针转为 SystemHandleInformationEx 指针,以读取 NumberOfHandles
	pInfo := (*SystemHandleInformationEx)(unsafe.Pointer(handleInfoPtr))
	numberOfHandles := pInfo.NumberOfHandles

	fmt.Printf("句柄总数: %d\n", numberOfHandles)

	// 计算第一个 Handle Entry 的起始地址
	// 第一个条目紧跟在 SystemHandleInformationEx 结构之后
	// 注意: SystemHandleInformationEx 自身的大小也要考虑进去
	firstEntryOffset := unsafe.Offsetof(pInfo.Reserved) + unsafe.Sizeof(pInfo.Reserved) // 这是 NumberOfHandles 和 Reserved 之后
	// 或者直接用 SystemHandleInformationEx 的大小,如果确定布局的话
	// startOfEntries := uintptr(handleInfoPtr) + unsafe.Sizeof(SystemHandleInformationEx{})
	// 更稳妥的方式是直接从 NumberOfHandles 字段后面开始计算地址,因为 C 结构里 Handles[1] 就是紧随其后的
    startOfEntries := uintptr(unsafe.Pointer(handleInfoPtr)) + unsafe.Offsetof(pInfo.NumberOfHandles) + unsafe.Sizeof(pInfo.NumberOfHandles) + unsafe.Sizeof(pInfo.Reserved)
    
    // 某些系统/文档实现可能将 Handle 数组放在 NumberOfHandles 之后(跳过 Reserved)
    // 实践中需要确认结构体准确布局, Windows 内部结构可能变化
    // 以下假设 Handles 紧随 NumberOfHandles, Reserved 之后
    entryPtr := unsafe.Pointer(startOfEntries)


	// 遍历所有句柄条目
	for i := uintptr(0); i < numberOfHandles; i++ {
		// 计算当前条目的指针
		// 注意指针运算的单位是 byte
		currentEntryPtr := unsafe.Pointer(uintptr(entryPtr) + i*systemHandleTableEntryInfoExSize)

		// 将当前指针转换为条目结构体指针
		entry := (*SystemHandleTableEntryInfoEx)(currentEntryPtr)

		// 现在可以安全地访问字段了
		fmt.Printf("  句柄 %d: PID=%d, Handle=0x%X, Object=0x%X, TypeIdx=%d\n",
			i,
			entry.UniqueProcessId,
			entry.HandleValue,
			entry.Object,        // 这就是内核对象地址!
			entry.ObjectTypeIndex,
		)

        // 在这里你可以根据 entry.UniqueProcessId 或 entry.HandleValue
        // 做进一步的操作,比如过滤特定进程的句柄等
	}
}

关键就在于 uintptr(entryPtr) + i*systemHandleTableEntryInfoExSize 这句。我们知道了第一个条目的起始地址 entryPtr 和每个条目的大小 systemHandleTableEntryInfoExSize,就可以通过 起始地址 + 索引 * 条目大小 算出第 i 个条目的内存地址,然后用 unsafe.Pointer 转成对应的结构体指针来访问。这才是处理 C 风格数组(尤其是不定长数组)的正确方式。

第四步:修正 NT_SUCCESS 判断

NtQuerySystemInformation 直接返回 windows.NTStatus (就是一个 uint32),而不是 Go 的 error 类型。所以,判断成功与否,应该直接比较这个状态码。

C/C++ 里的 NT_SUCCESS(status) 宏通常是判断 status 值是否小于 0x80000000(即最高位是否为0)。0 代表成功,正数通常是信息性状态(如 STATUS_INFO_LENGTH_MISMATCH),负数(最高位为1)代表错误。

在 Go 里,最直接的方式是:

// 调用后...
status := windows.NtQuerySystemInformation(...)

// 检查是否是特定期望的状态
if status == windows.STATUS_SUCCESS {
	fmt.Println("调用成功!")
} else if status == windows.STATUS_INFO_LENGTH_MISMATCH {
	fmt.Printf("缓冲区太小,需要 %d 字节\n", requiredLength)
    // 进行第二次调用...
} else {
    // 其他错误状态,直接打印十六进制值更容易调试
	fmt.Printf("调用失败,NTSTATUS = 0x%X\n", status)
    // 这里可以进一步查找 0x... 对应的具体错误含义
    // windows.NTStatus(status).Error() 可以尝试转换成字符串,但不一定总有意义
    // fmt.Printf("错误信息尝试:%s\n", windows.NTStatus(status).Error())
}

// 如果你非要模仿 NT_SUCCESS 宏,可以这样做:
func isNtSuccess(status windows.NTStatus) bool {
    // NTSTATUS 是 uint32,需要转成 int32 判断符号位
	return int32(status) >= 0
}

// 使用 isNtSuccess
if isNtSuccess(status) && status != windows.STATUS_INFO_LENGTH_MISMATCH {
    // 处理成功的情况 (排除掉还需要二次调用的 INFO_LENGTH_MISMATCH)
} else if status == windows.STATUS_INFO_LENGTH_MISMATCH {
    // ...
} else {
    // 处理错误
}

直接比较 status == windows.STATUS_SUCCESSstatus == windows.STATUS_INFO_LENGTH_MISMATCH 更清晰明了,推荐这种方式。之前的那个 NtSuccess 函数确实不对。

安全性和进阶技巧

安全建议

  1. 谨慎使用 unsafeunsafe 是把双刃剑。用它进行指针转换和运算时,务必清楚自己在做什么,算对偏移量和大小,否则很容易搞崩程序或引入难以察觉的 bug。类型转换要绝对确保 Go 结构体布局与内存数据一致。
  2. 内存管理 :用 LocalAllocVirtualAlloc 分配的内存,一定要用对应的 LocalFreeVirtualFree 释放。defer 是个好帮手。
  3. 权限问题 :调用 NtQuerySystemInformation 获取某些敏感信息(虽然 SystemExtendedHandleInformation 通常还好)可能需要特定权限,比如 Debug 权限。如果调用失败,除了检查代码逻辑,也要考虑权限因素。
  4. 错误处理要到位 :每次调用 Windows API 后,都应该检查返回值/状态码,而不是假设它一定会成功。

进阶技巧

  1. VirtualAlloc vs LocalAlloc :对于可能非常大的内存分配(比如句柄表可能很大),VirtualAllocLocalAlloc 更灵活,能提供更细粒度的内存控制(比如页大小对齐、保护属性等)。不过对于一般大小的缓冲区,LocalAlloc 更简单方便。
  2. 句柄过滤 :拿到所有句柄列表后,你通常只关心特定进程的句柄,或者特定类型的句柄。遍历时,可以用 entry.UniqueProcessId 过滤进程,用 entry.ObjectTypeIndex 过滤类型。ObjectTypeIndex 是个数字,需要配合其他 API(如 NtQueryObject 配合 ObjectTypeInformation)才能查询到对应的类型名称(如 "File", "Process", "Thread" 等),这会复杂一些。
  3. 64位架构 :在 64 位 Windows 上,指针、句柄值 (HANDLE)、ULONG_PTR 等都是 64 位的。使用 uintptr 作为 Go 类型来映射它们是正确的选择,能保证在 32 位和 64 位平台上的兼容性。确保你的结构体定义考虑了 64 位下的对齐和字段大小。

通过上面这些修正和解释,你应该能正确地使用 sys/windows 包通过 NtQuerySystemInformationSystemExtendedHandleInformation 获取系统句柄列表,并从中提取出 HandleValueObject (内核地址),同时避免那个让人头疼的 0xc0000374 崩溃了。核心在于:正确的结构体定义、正确的内存分配策略(两步调用法)、以及正确的指针运算来遍历结果