一个 PE 文件加载进内存后,它自己的 DOS 头、NT 头、节区表就摊在进程地址空间的开头,用户态直接能读。不用 LoadLibrary,不用 MapViewOfFile,一个 GetModuleHandle(NULL) 拿到基址,剩下的全靠加法一层层剥下去。这篇记的是从 MZ 一路剥到节区表、打印每个节区的名字地址属性、再拆出 R/W/X 的完整过程,代码自己写、能编译能跑,文末有真实输出。
剥的过程中我把所有宏都拆掉了——IMAGE_FIRST_SECTION 这种现成宏能一步到位,但它把"节区表到底怎么算出来的"藏掉了。手写 (BYTE*)nt + 4 + 20 + SizeOfOptionalHeader,每一步加的是什么看得清清楚楚。过程中卡过、想错过的地方单独成节,留着当时怎么想错的。内容均在合法授权的隔离靶场环境内学习验证。
环境:Windows 11 家庭中文版 10.0.26200,MSYS2 gcc 16.1.0,x64。
PE 文件从头到尾一层套一层:DOS Header 在最前,里面 e_lfanew 字段告诉你 PE 头在哪;PE 头又是三层内嵌——PE 签名、COFF File Header、Optional Header;Optional Header 走完,紧跟着才是节区表。

上面这张是整体套娃。要手算偏移,还得换成阶梯视角——每一层在什么地址、到下一层跳多远、哪些是固定大小、哪些必须读字段才知道:

阶梯图里只有两处是变长:DOS Stub,加载器不看,靠 e_lfanew 直接绕过;和 Optional Header,靠 COFF 头里的 SizeOfOptionalHeader 字段绕过。其余每层长度写死,加固定数即可。下面这张速查卡把写代码时要用到的 <windows.h> 结构体和容易踩的坑列在一起,后面每段代码对照着看。
写 PE 解析不靠 struct.unpack,<windows.h> 已经把所有结构体定义好了,强转指针就能按字段读。要记的只有下面五个,和每个上头容易栽的坑。
五个坑,写之前过一遍:
关于节区表起点:<windows.h> 提供了 IMAGE_FIRST_SECTION(nt) 宏,内部就是 (BYTE*)nt + 4 + 20 + SizeOfOptionalHeader 这一行。本文刻意不用宏、手写加法,是为了把"节区表到底怎么算出来的"摊开看清楚——写生产代码时用宏更简洁,但要懂它藏了什么。
节区表起点的手算链,没有减法,没有宏,每一步都是"跳过某段长度":
关键在最后一步:Optional Header 长度不固定。PE32(32位)标准 224 字节,PE32+(64位)标准 240 字节,但实际长度由 COFF 头里的 SizeOfOptionalHeader 字段说了算——加载器信这个字段。所以你不能写死 +240,必须读字段。
第一步拿基址。GetModuleHandle(NULL) 返回的是 EXE 自身在当前进程地址空间里的加载基址。注意这是运行时实际地址,受 ASLR 影响,每次启动都不同,不能写死。
把 hMod 强转成 IMAGE_DOS_HEADER*,就能按结构体字段读 DOS 头了。DOS Header 64 字节里只有两个活字段:e_magic(MZ 标志)和 e_lfanew(PE 头偏移)。其余 16 个旧 DOS 字段加载器不看。
e_lfanew 存的是个偏移数字(0x80),不是地址也不是内容。它告诉你"PE 签名在距文件开头第 0x80 字节处"。
e_lfanew 加上基址,就到了 NT 头。NT 头是个三层内嵌结构体 IMAGE_NT_HEADERS:
这里必须 (BYTE*)hMod 强转。HMODULE 是句柄,不能直接 + 整数;强转成单字节指针后,加几就是前进几字节。不加这步强转,编译器直接拒。
IMAGE_NT_HEADERS 内部三层:
我一开始分不清什么时候用 -> 什么时候用 .。规则其实简单:
判断依据是"这个字段是指针还是内嵌结构体"。指针用 ->,内嵌本体用 .。nt->FileHeader 拿到的是本体,本体上取字段就是 .。
NT 头里 COFF File Header 固定 20 字节,7 个字段,我用到 3 个:
NumberOfSections 告诉我节区有几个(19),SizeOfOptionalHeader 告诉我 Optional Header 多长(0xF0=240)——后者正是算节区表起点要用的那个变长。
到节区表这一步,把前面的加法链写成一行:

sizeof(nt->Signature) = 4,sizeof(IMAGE_FILE_HEADER) = 20,加 SizeOfOptionalHeader。等价于 (BYTE*)nt + 4 + 20 + 0xF0。我没用 IMAGE_FIRST_SECTION 宏——那个宏内部就是这一行,但写出来每一步加的是啥一目了然,宏把它藏了。
拿到 section 指针后,它指向第一个节区头。节区头每个 40 字节,NumberOfSections 个连排,所以 section[i] 就是第 i 个节区。这里 section 是指针,section[i] 已经解引用成本体,所以下面访问字段全用 .。
节区头 40 字节,关键字段:Name[8]、Misc.VirtualSize、VirtualAddress、SizeOfRawData、PointerToRawData、Characteristics。下面三个是反复踩过的。
节区名用 %.8s 不是 %s。Name 是定长 8 字节数组,不保证有 \0 结尾。.text 只有 5 个字符,剩下 3 字节是别的数据。用 %s 会一直读直到碰到 \0,越界读到下一个节区头。%.8s 强制最多打 8 字符,安全。
VirtualSize 走 Misc.VirtualSize。Misc 是个联合体(union),VirtualSize 和 SizeOfRawData 共用同一片 4 字节内存。访问必须 section[i].Misc.VirtualSize,不能 section[i].VirtualSize,那是联合体本身的名字,不是字段。
内容/偏移/地址三分。VirtualAddress 是节区加载到内存后的 RVA(相对基址的位置),PointerToRawData 是节区在文件里的偏移,SizeOfRawData 是文件里的大小。这三个是不同坐标系的东西,别混。D8 的时候我反复栽在这——把"在文件第几字节"和"加载到内存哪里"搅在一起。VirtualAddress 是内存坐标,PointerToRawData 是文件坐标。
节区属性全在 Characteristics 字段里,32 位,每一位/几位代表一个属性。执行/读/写看高三位:
拆权限的代码:
这句话我卡了很久。我一开始以为 & "运算了就算成功=1"——"1与1还是1与0他不都是相当于计算成功了吗 怎么不应该都是1吗"。
错在没分清"运算执行了"和"运算结果是多少"。& 是按位与,逐位比较:
? 1 : 0 判断的是运算结果的数值:结果非 0 → 输出 1,结果等于 0 → 输出 0。不是"运不运算"。
0x20000000 这个掩码只开第 29 位,其余 31 位全是 0。按位与时,被测值第 29 位是 1 → 1&1=1 → 结果非 0 → 输出 1(有权限);被测值第 29 位是 0 → 0&1=0 → 结果 0 → 输出 0(无权限)。其余位被掩码的 0 全部屏蔽成 0,不影响判断。

拿 .text 的 0x60000020 验证一遍:
.text = R-X,正常代码段。掩码像一张只开一个孔的挡板,挡住其他位,只让目标那一位的值透过来。
我口语说过"第30位=可执行",但严格按 2^N 标准编号 0x20000000 = 2^29 = 第 29 位(从 0 数)。从 1 数就是"第 30 位"。指同一个位,两种数法。以后用 2^N 标准更精确,避免歧义。
把 X/R/W 三个拼起来,就能看一个节区是 R-X / RW- / R-- 还是 RWX。正常程序:
代码段不可写、数据段不可执行。一旦出现 RWX 节区——既能写又能执行——说明这段内存可以边写边跑:壳的解密器把代码写进去再执行、Shellcode loader 注入后改属性执行,都需要 RWX。杀软和 EDR 扫到 RWX 节区会标记。
我的 demo 没有 RWX,正常程序。
代码跑出来 ImageBase: 0x7FF7917D0000,不是预期的 0x140000000。一开始以为取错字段了,后来想明白这是 ASLR。

EXE 编译时,链接器在 Optional Header 的 ImageBase 字段写死一个期望基址(MSVC 默认 0x140000000)。运行时加载器为了防攻击,把它加载到随机地址(0x7FF7917D0000)。加载器把实际基址回填写进了内存里的 OptionalHeader.ImageBase 字段——所以运行时读这个字段,拿到的是回填后的实际值,恰好等于 GetModuleHandle(NULL)。
这正好印证 D9 那个薄弱点:
文件里的 ImageBase 是链接器写的期望值,内存里的 ImageBase 在加载时被 ASLR 改成了实际加载地址。我这个程序运行时读的是内存里被改过的那个,所以是 0x7FF7917D0000。
输出解读:
19 个节区比正常 58 个多,是 gcc 默认带调试信息 + CRT 静态链接摊开的。用 /MD /RELEASE /MERGE 可以压到 34 个,但压缩节区数本身也是启发式特征——节区太少同样会被杀启发式规则标记。
既然能读 NumberOfSections,就能想到动手脚。加载器信这个字段:它读前 N 个节区头、只映射前 N 个节区。把 19 改成 5,文件照样能跑(前 5 节区够用),后面 14 节区的数据还在文件里,但加载器视而不见。
藏东西的空间就出来了:恶意代码放进"加载器不读的第 6~19 节区",杀软若只信 NumberOfSections 就扫不到。
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
最后于 3小时前
被aln1lam编辑
,原因: