在之前我们介绍双机调试的时候,在使用windbg的时候或者ida反编译pe文件的时候,会涉及到找不到符号文件的情况,具体可以看这篇文章,https://daliu.net/posts/20250123/#tips,当时推荐了一个方法就是用一个工具,pdb downloader这个工具,当时还介绍如何把文件重新编译成https的协议。今天这篇文章是基于这个前提的一个补充,在细致的研究下这个事情。
PE文件里的调试信息
我们之前介绍过PE文件的结构,可以参考这篇文章,https://daliu.net/posts/20250117/,除了我们介绍的几个常用表外,还有一个调试相关的表,在_IMAGE_DATA_DIRECTORY
中,第7个就是调试信息起始地址和大小,所以我们可以通过这个找到调试信息,我们用010editor试一下:

指向的位置其实是一个结构体,该结构体描述了对应调试的信息,调试信息的结构体如下所示,其中第8个参数PointerToRawData
标识调试信息在文件中的偏移位置。
1
2
3
4
5
6
7
8
9
10
|
typedef struct _IMAGE_DEBUG_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Type;
DWORD SizeOfData;
DWORD AddressOfRawData;
DWORD PointerToRawData;
} IMAGE_DEBUG_DIRECTORY, *PIMAGE_DEBUG_DIRECTORY;
|
在工具中我们找到这个地址查看下具体的信息情况,如下图,可以知道第7个DWORD就是对应的偏移地址,我们继续找这个地址的内容

在此之前,首先看第五个参数,也就是第四个DWORD,是0x2,这个type参数,标识调试信息的类型,具体有哪些类型可以看https://learn.microsoft.com/zh-cn/windows/win32/debug/pe-format#the-debug-section,当是0x2的时候,标识调试信息为IMAGE_DEBUG_TYPE_CODEVIEW
也就是**Visual C++**调试信息。
这个信息的头部应该是有确定规律的,但是资料太少也就只能从之前开源的工具里直接搬过来了,大概应该是下面的样子:
1
2
3
4
5
6
7
8
9
|
public struct IMAGE_DEBUG_DIRECTORY_RAW
{
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)]
public char[] format;
public Guid guid;
public uint age;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 255)]
public char[] name;
}
|
其中四个参数分别如下:
1
2
3
4
5
6
7
|
format:4 个字符的格式标识符。
在Visual Studio 6.0中,调试头开始处是一个NB10签名。
在Visual Studio .NET中,这个头开始处是RSDS,所以现在基本看的都是RSDS
guid:全局唯一标识符,用于标识调试信息的唯一性。
age:无符号整数,表示调试信息的版本或时间戳。
name:最多 255 个字符的名称字段,用于存储调试信息的名称或路径。
|
我们跟着上图圈画的地址找过去,可以看到这个format的4个字符

关于上面这部分的信息还有一个相关的介绍,在网上找到的一段,可以参考:
到目前为止,最流行的调试信息格式是PDB文件。PDB文件实质上是CodeView格式调试信息的发展。一个类型为IMAGE_DEBUG_TYPE_CODEVIEW的调试目录标志着PDB信息的存在。如果你检查由这个元素指向的数据,会发现一个短的CodeView格式的头部。这个调试数据主要是一个外部PDB文件的路径。在Visual Studio 6.0中,调试头开始处是一个NB10签名。在Visual Studio .NET中,这个头开始处是RSDS。
在Visual Studio 6.0中,可以使用/DEBUGTYPE:COFF链接器选项来生成COFF调试信息。Visual Studio .NET将这项功能移除了。对于经过优化的x86代码,由于函数可能没有正常的栈帧,所有使用帧指针省略(Frame Pointer Omission,FPO)调试信息。FPO数据允许调试器定位局部变量和参数。
有两种OMAP调试信息仅用于Microsoft的程序。Microsoft内部使用一种工具对可执行文件中的代码进行重新排列以减少分页。(它所做的不仅仅是Working Set Tuner所能做到的。)OMAP信息让工具可以在调试信息中的原始地址与重排后的代码中的新地址之间进行转换。
顺便说一下,DBG文件也包含了一个类似于我上面讲的调试目录。DBG文件流行于Windows NT 4.0时代,它们主要包含COFF调试信息,但是Windows XP偏爱PDB文件而将它们淘汰了。
关于GUID,这是一个标准的结构体,我们可以参考下面的结构体信息
1
2
3
4
5
6
|
typedef struct _GUID {
unsigned long Data1;
unsigned short Data2;
unsigned short Data3;
unsigned char Data4[8];
} GUID;
|
所以上文的Data1:4DBE1441,Data2:82FF,Data3:4156,Data4:845CD3BD8E654E56
然后age,就是类似于发型版本,或者编译的版本,上文的图片看,这个参数得1
然后就是名字了,ntkrnlmp.pdb
在之前我们是直接用pdbdownload的工具来直接下载的,但是这次,我们尝试用代码解析对应的信息,不过既然如此,我们依然是用go来实现这个功能,因为go有相关的库,而且实现起来很方便。这次引入的库是github.com/Velocidex/go-pe
这个库,我们直接get然后写如下代码。
1
|
go get github.com/Velocidex/go-pe
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
package main
import (
"fmt"
"log"
"os"
pe "github.com/Velocidex/go-pe"
)
func main() {
// 打开 PE 文件
file, err := os.Open("D:/kernel_study/blog/64/ntoskrnl.exe")
if err != nil {
log.Fatalf("Failed to open file: %v", err)
}
defer file.Close()
// 创建 PEFile 实例
peFile, err := pe.NewPEFile(file)
if err != nil {
log.Fatalf("Failed to parse PE file: %v", err)
}
// 打印 PDB 文件名和 GUIDAge
fmt.Println("PDB File Name:", peFile.PDB)
fmt.Println("GUID Age:", peFile.GUIDAge)
return
}
|
我们可以看到如下结果,这个跟上文的GUID 和Age,是完全一致的,go封装的包真的非常方便。

不过话又说回来了,按照我们最终的需求,我们是要下载这个文件到本地的,因为我们需要把对应的符号文件下载到本地配合对应的调试工具进行调试,所以我们接下来需要拼合向符号文件服务器请求所需要的链接:
链接的结构大概如下:
1
2
3
|
uri := fmt.Sprintf("/download/symbols/%s/%s/%s", peFile.PDB, peFile.GUIDAge, peFile.PDB)
host := "msdl.microsoft.com"
url := fmt.Sprintf("https://%s%s", host, uri)
|
我们用go实现请求并获取文件并保存到本地,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
pe "github.com/Velocidex/go-pe"
)
func main() {
// 打开 PE 文件
file, err := os.Open("C:/Windows/System32/ksetup.exe")
if err != nil {
log.Fatalf("Failed to open file: %v", err)
}
defer file.Close()
// 创建 PEFile 实例
peFile, err := pe.NewPEFile(file)
if err != nil {
log.Fatalf("Failed to parse PE file: %v", err)
}
// 打印 PDB 文件名和 GUIDAge
fmt.Println("PDB File Name:", peFile.PDB)
fmt.Println("GUID Age:", peFile.GUIDAge)
// 构建下载 URL
uri := fmt.Sprintf("/download/symbols/%s/%s/%s", peFile.PDB, peFile.GUIDAge, peFile.PDB)
host := "msdl.microsoft.com"
url := fmt.Sprintf("https://%s%s", host, uri)
// 下载 PDB 文件
resp, err := http.Get(url)
if err != nil {
log.Fatalf("Failed to download PDB file: %v", err)
}
defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
log.Fatalf("Failed to download PDB file: %s", resp.Status)
}
// 保存下载的 PDB 文件
pdbName := peFile.PDB
outputFile, err := os.Create(pdbName)
if err != nil {
log.Fatalf("Failed to create output file: %v", err)
}
defer outputFile.Close()
_, err = io.Copy(outputFile, resp.Body)
if err != nil {
log.Fatalf("Failed to save PDB file: %v", err)
}
fmt.Printf("PDB file saved as: %s\n", pdbName)
}
|
最后得到如下结果:

我们可以尝试用之前的小工具下载的文件和这个对比一下,是一样的,说明没问题。
有两个需要注意的地方,用上文推荐的库的时候,如果是想要获取更多pe结构的问题,直接用下面的代码或的peFile文件只有几个信息,并没有更多的配置文件。
1
2
3
4
5
|
// 创建 PEFile 实例
peFile, err := pe.NewPEFile(file)
if err != nil {
log.Fatalf("Failed to parse PE file: %v", err)
}
|
如果想要知道如何获取对应信息,可以直接点进这个NewPEFile
方法,会跳转到一个api.go
的文件里,通过下图,我们可以清晰看到,是新生成一个profile文件,然后用profile文件依次解析,其中reader就是打开文件的os.file的结构体。

第二个注意点是,微软的符号库不是总能访问,有时候就不灵了,所以需要某些科学的方法,这时候,代码需要修改,具体如何做这里不介绍了,最简单的就是直接把这段代码发给ai,让它给你修改为目标的代码,亲测,一次就好使。
好了,今天就介绍到这里了,回头这个也可以整理到我们wails小工具里。