用 Python 手搓 268 字节 / 387 字节的 PE EXE
—— 顺便彻底搞懂《到底能多小》
关键词:PE 格式、最小可执行文件、i386 / AMD64、Windows 11、逐字节构造、加载器实证
实测环境:Windows 11(x64, build 10.0.26100.0)
所有生成器与测试脚本均为纯 Python,不依赖任何汇编器/编译器/链接器
TL;DR(太长不看版)
在 Windows 11 (x64, 26100) 上,我最终做出了两个“最小可运行”的 PE EXE:
| 口径 | 位宽 | 文件大小 | 内容 | 验证结果 |
|---|---|---|---|---|
| 32 位 i386 (PE32) | 32-bit | 268 字节 | xor eax,eax; ret | 运行退出码 0,且这是当前加载器硬性长度下限 |
| 64 位 AMD64 (PE32+) | 64-bit | 387 字节 | 同上 | 运行退出码 0 |
关键结论:“最小”不是玄学,而是当前加载器划死的硬边界:
- 32 位:267 字节被拒、268 字节通过 —— 差一个字节都不行。
- 64 位:受
FileAlignment ≥ 0x80、可选头 ≥ 0x88、必须有可执行区段三条硬约束锁定在 387。
为什么把 268 字节的 32 位方案“翻译”到 64 位,反而膨胀到 387?因为 64 位加载器把三条让 32 位变小的“历史宽松行为”全部堵死了。本文将把来龙去脉、每一个字节、每一组实验都讲清楚。
目录
- 缘起:我想知道最小的 EXE 到底多大
- 前置知识:PE 文件到底长什么样
- 一次“正经”的最小化:先写出 387 字节的 64 位 EXE
- 差点翻车:387 不是全局最小
- 第二次最小化:268 字节的 32 位 EXE(一个字节也不能再少)
- 为什么 32 位能更小,而 64 位不能?逐条对照
- 字节级拆解:268 与 387 到底都有什么
- 完整实验方法(可直接复现)
- 代码清单
- 结论与展望
- 参考与延伸阅读
1. 缘起:我想知道最小的 EXE 到底多大
“一个能跑的 Windows 可执行文件,理论上最小能到多少字节?”这个问题一直很吸引我。网上最著名的答案之一是 TinyPE-on-Win10 那个 268 字节、还能弹消息框的 PE。于是我也想在自己这台 Windows 11 上复现一次,亲手做出“最小”的 EXE。
但“最小”有个麻烦的定义问题:
- 如果只用 64 位 AMD64、并走“标准一区段”路线,你会得到 387 字节。
- 如果切到 32 位 i386、并复用头部字节当代码,你能压到 268 字节。
- 而“还能不能再小”,取决于当前加载器到底接受多小的空洞——这无法从规格书中直接推导,必须靠实验验证。
这篇博客的核心,就是用实验而非脑补,把这几个数字的边界一一敲实。
2. 前置知识:PE 文件到底长什么样
Windows 可执行文件(EXE / DLL)遵循 PE(Portable Executable) 格式。逐字节手工构造时,我们主要关心这几块(按文件中的先后顺序):
+------------------+-------------------------------------------+
| DOS 头/存根 | 开头必须是 'MZ';文件偏移 0x3C 处是 e_lfanew |
| | 它告诉加载器“PE 头开始于文件哪个偏移” |
+------------------+-------------------------------------------+
| PE 签名 | 4 字节 'PE\0\0' |
+------------------+-------------------------------------------+
| COFF 头 (20B) | 机器类型(i386/AMD64)、区段数量、可选头长度等|
+------------------+-------------------------------------------+
| 可选头 | PE32 (0x10B) 或 PE32+ (0x20B);入口点、基址、|
| | 对齐、子系统和目录指针都在这里 |
+------------------+-------------------------------------------+
| 区段头(们) 40B/个| 每个区段(.text/.data/...)的虚拟地址、文件偏移|
+------------------+-------------------------------------------+
| 区段数据 | 真正的机器码 / 数据 |
+------------------+-------------------------------------------+关键点(也是后面全部“最小化手段”的抓手):
- DOS 头不必是完整的 64 字节。加载器只要能在
0x3C读到e_lfanew、从那里找到PE\0\0就行。若能想办法让0x3C处的 4 个字节“恰好”等于某个小数值,就能让 DOS 头变得极短。 e_lfanew字段可以“重叠”进别的字段。因为加载器只关心0x3C这个偏移,而那个偏移在文件里同时也是其他结构的字节——巧妙设计可以让两者自洽。- 机器码可以由“非代码的头部字节”充当。入口点 (
AddressOfEntryPoint) 只要指向一个被映射成可执行、且字节恰好解译成合法指令的位置即可,不必单独有.text区段(至少 32 位时代允许)。
记住这三点,后面的 268 字节就水到渠成。
想深入了解 PE 结构,可参考微软的 PE/COFF 规范,或 Stack Overflow 上的 “最小可能的 Windows PE” 讨论。
3. 一次“正经”的最小化:先写出 387 字节的 64 位 EXE
我第一个尝试的是最“正经”的路子:64 位 AMD64、单 .text 区段、零导入。目标是只保留“必然得存在的”东西,其余全砍。
3.1 哪些能砍,哪些不能砍
程序本体:我只想做“什么都不干、正常退出”,那代码只需两条指令:
xor eax, eax ; 33 C0 清空 eax,作为退出码来源
ret ; C3 返回加载器在入口压栈的返回地址,触发进程终止设计洞见:x64 Windows 在跳入入口点之前,会在栈上压一个返回地址,它指向加载器的“终止路径”。我们ret回去时,EAX的值就是进程退出码。所以xor eax,eax; ret就能以退出码 0 干净退出,完全不需要调用ExitProcess、不需要导入表、不需要任何kernel32符号。
可以砍掉的经典部件:DOS 存根代码、全部数据目录、导入表、重定位表、资源、TLS、调试信息、以及除 .text 外的所有区段。
砍不掉、且决定下限的硬约束(后面用实验逐一验证):
- 64 位加载器要求 至少 1 个可执行区段;
- 可选头(PE32+)长度必须 ≥ 0x88 字节;
FileAlignment(文件对齐)必须 ≥ 0x80。
3.2 387 是怎么凑出来的
0x000 64 DOS 头 (MZ + e_lfanew=0x40,无存根)
0x040 4 'PE\0\0'
0x044 20 COFF 头 (machine=0x8664, 1 区段, optional=0x88)
0x058 136 可选头 PE32+ (0x88 字节),入口点 = 0x180
0x0E0 40 区段头 .text
---- 以上头部共 0x108 = 264 字节 ----
0x108 120 对齐空隙(因为 FileAlignment=0x80,代码起点被推到 0x180)
0x180 3 '33 C0 C3' (xor eax,eax; ret)
---- 总计 0x180 + 3 = 0x183 = 387 字节 ----注意那个 120 字节的“对齐空隙” 吗?头部明明只要 264 字节,却因为 FileAlignment=0x80 必须把代码起点对齐到 0x180,于是白白多了 120 字节的零填充。要想做得更小,要么让 FileAlignment 更小(64 位加载器不允许),要么换路子——这就是第 5 节 32 位方案的出发点。
3.3 那 7 个“再小一档”的尝试,全被加载器拒了
我逐项改了 64 位布局里的常量,用 CreateProcess 逐个实测:
| 尝试 | 结果 | 含义 |
|---|---|---|
FileAlignment 降到 0x40 / 0x20 / … / 0x01 | REJECT(216) | ERROR_EXE_MACHINE_TYPE_MISMATCH |
| 可选头降到 0x84 | ERROR 193 | 镜像异常 |
| 可选头降到 0x80 及以下 | REJECT(216) | 同上 |
SectionAlignment 提为 0x100 / 0x200 | ERROR 193 | 必须等于 FileAlignment |
| 入口点指到任意区段之外 | RUN 0xC000007B | STATUS_INVALID_IMAGE_FORMAT,入口必须落在可执行区段内 |
| 区段与头重叠 / 入口指向头部 | ERROR 193 或 0xC000007B | 64 位禁止“零区段入口” |
于是 64 位就被锁死在 387。
4. 差点翻车:387 不是全局最小
讲到这里,如果我就此下结论说“最小是 387”,那就错了,而且错得很有代表性。
问题在于:我在“最小化”时默认只走 64 位这条路。但问题从来是“Windows 上最小的 EXE”,并没有限定必须是 64 位。后来我拿到一个别人做的 268 字节 32 位 PE,在我同一台 Win11 上直接就能跑——这立刻推翻了我“387 是全局最小”的判断。
教训:“理论最小”必须显式声明你的约束空间(几位、要不要真实功能、要不要可复现)。否则“最小”这个词是会骗人的。
于是我把问题拆成两个独立的、都同样有趣的问题:
- 64 位 AMD64 的最小是多少? → 387(标准一区段)。
- 32 位 i386 的最小是多少? → 268(复用头部字节当代码)。
5. 第二次最小化:268 字节的 32 位 EXE(一个字节也不能再少)
5.1 它能更小的两个“杀手锏”
与 64 位不同,i386 加载器保留了相对宽松的历史行为,让两条压缩手法成立:
(A) e_lfanew=4:把 DOS 头压到 4 个字节
正常 DOS 头是 64 字节,加载器在 0x3C 读 e_lfanew。如果我们能让 0x3C 这 4 个字节的数值恰好等于 4,那么:
- 加载器在这读到
e_lfanew=4,就去文件偏移4找PE\0\0; - 于是 DOS 头只需要
MZ两个字节 + 2 个00就够,省掉 60 字节; - 而那个“恰好 =4”的字节,同时是 PE32 可选头里
SectionAlignment的值(我设成 4)。一个字节承担两个职责——这就是“重叠”。
(B) 零区段 + 头部当代码:不需要单独的 .text
i386 加载器允许入口点直接落在“文件被完整映射出来的头部区”里执行。于是我把 xor eax,eax; ret(33 C0 C3)直接放到可选头之后的位置(偏移 0x7C),AddressOfEntryPoint=0x7C 指向它即可,完全不需要区段头。
5.2 268 字节的布局
0x00 4 'MZ\0\0'
0x04 4 'PE\0\0' <- 加载器从 0x3C 读到 e_lfanew=4,来这里找 PE
0x08 20 COFF 头 (machine=0x014C i386, sections=0, optional=0x60)
0x1C 96 可选头 PE32 (0x60 字节),SectionAlignment=FileAlignment=4,入口点=0x7C
0x7C 3 '33 C0 C3' <- xor eax,eax ; ret
0x7F ... 零填充到 0x10C
---- 总计 0x10C = 268 字节 ----注意:整个文件没有区段头、没有数据目录(NumberOfRvaAndSizes=0)、没有任何 ExitProcess 调用。
5.3 268 是“硬下限”:差一个字节都跑不起来
为了确认“268 是不是还能再压”,我对这个布局做了最干净的实验——逐字节裁剪文件尾部:
| 文件长度 | 结果 |
|---|---|
| 266 | REJECT(216) ERROR_EXE_MACHINE_TYPE_MISMATCH |
| 267 | REJECT(216) |
| 268 | ✅ 运行,退出码 0 |
| 269 | ✅ 运行 |
也就是说,哪怕我把尾巴上全是零的字节一个个删掉,只要总长少于 0x10C=268,当前 i386 加载器就直接拒绝。这个 268 与“代码多精简”无关——它是加载器自己划死的最小允许文件长度。这正是“硬下限”和“我还没压到位”的区别:前者是物理边界,后者是还能努力的空间。
换句话说:如果你在网上看到“更小的 32 位 PE”,要么它跑在更老的 Windows 上(当时加载器没有这个 268 长度下限),要么它并不是一个能被当前加载器正常装入的镜像。
6. 为什么 32 位能更小,而 64 位不能?逐条对照
把两边放在一张表里,差异一目了然:
| 维度 | 32 位 i386 (268) | 64 位 AMD64 (387) | 为什么 |
|---|---|---|---|
| 可选头 | PE32 最小 0x60 (96B) | PE32+ 最小 0x88 (136B) | 64 位可选头里 ImageBase 是 8 字节、字段更多 |
FileAlignment | 可以 = 4 | 必须 ≥ 0x80 | 64 位加载器对文件对齐要求更严 |
| DOS 头 | 可用 e_lfanew=4 压到 4 字节 | 需标准 64 字节 DOS 头 | 32 位保留的历史兼容行为 |
| 区段 | 0 个(头部即代码) | 必须 ≥1 个 | 64 位加载器禁“零区段入口” |
| 最终大小 | 268 | 387 | 上面 4 条累加 |
一句话:64 位保留下来每一项构造要求(有可执行区段、更严对齐、更严可选头、更严 DOS 头)都比 32 位苛刻,所以同样的“最小”,64 位更做不到 32 位那么小。
换句话说,268 这个数字其实是 32 位“历史宽松”的遗产;一旦去掉这些兼容性,(在只能保留这些硬约束的前提下)体积就回到了 387 这个量级。
7. 字节级拆解:268 与 387 到底都有什么
7.1 32 位 268 字节:完整字段
偏移 字节 说明
00 MZ DOS 签名
02 00 00 (与 e_lfanew=4 重叠相关的占用)
04 PE 00 00 PE 签名
08 4C 01 machine=0x014C (i386)
0A 00 00 sections = 0
18 60 00 size of optional header = 0x60
1A 03 01 characteristics = 0x0103 (RELOCS_STRIPPED | 32BIT_MACHINE | EXEC)
1C 0B 01 optional magic = 0x10B (PE32)
2C 7C 00 00 00 AddressOfEntryPoint = 0x7C
...
3C 04 00 00 00 SectionAlignment = 4 <- (e_lfanew 也正好是 4)
40 04 00 00 00 FileAlignment = 4
...
68 02 00 subsystem = 2 (WINDOWS_GUI)
...
7C 33 C0 C3 xor eax,eax ; ret7.2 64 位 387 字节:完整字段
偏移 字节 说明
00 MZ DOS 签名 + e_lfanew=0x40
40 PE 00 00 PE 签名
44 64 86 machine=0x8664 (AMD64)
46 01 00 sections = 1
58 0B 02 optional magic = 0x20B (PE32+)
...
68 00 18 ... AddressOfEntryPoint = 0x180
...
74 00 00 40 00 ... ImageBase = 0x400000
...
8A 03 00 subsystem = 3 (WINDOWS_CUI)
E0 .text 区段头 (VA=0x180, 可读可执行)
180 33 C0 C3 xor eax,eax ; ret代码本体在两种情况下是一模一样的 3 个字节(33 C0 C3),差价全部来自格式层面的硬开销,而不是程序逻辑。
8. 完整实验方法(可直接复现)
所有结论都来自对真实加载器的直接询问,而非规格书背诵。这里给出可复现的骨架。
8.1 探测一个文件当前加载器是否接受 + 退出码
import subprocess, os
def probe(path):
try:
r = subprocess.run([os.path.abspath(path)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=4)
return f"RUN exit=0x{r.returncode & 0xffffffff:X}"
except subprocess.TimeoutExpired:
return "TIMEOUT"
except OSError as e:
return f"REJECT winerror={e.winerror}" # 216/193 等8.2 找“硬长度下限”(以你的 generate 函数为例)
def hard_floor(make_bytes):
# make_bytes(want_len) 生成指定长度、其余字段保持一致的镜像
lo, hi = 0, 4096
# ... 二分或线性扫描,用 probe() 找“拒绝->通过”的分界线实测分界:32 位267 REJECT / 268 RUN;64 位基线 387,截到 380 会0xC0000005(因为不小心把只读/映射区域弄失效或破坏了某字段)。
8.3 跑通这两个交付物
# 32 位最小 (268 字节)
python make_tiny32.py -o min32.exe
& .\min32.exe # exit 0
# 64 位最小 (387 字节)
python make_pe_win64.py -o min64.exe
& .\min64.exe # exit 09. 代码清单
| 文件 | 作用 |
|---|---|
make_tiny32.py | 生成 268 字节的 32 位 i386 PE(主交付) |
make_pe_win64.py | 生成 387 字节的 64 位 AMD64 PE |
pe_inspect.py | 解析 PE 结构 / hexdump 诊断工具 |
9.1 make_tiny32.py(核心,逐字节)
关键逻辑:
f = bytearray(268) # 加载器下限 0x10C = 268
f[0:2] = b'MZ'
f[0x04:8] = b'PE\x00\x00' # 让偏移 0x3C 的 e_lfanew==4 指向这里
p32(0x3C, 0x04) # e_lfanew -> 找偏移4的PE签名
p16(0x08, 0x014C) # machine = i386
p16(0x0A, 0) # sections = 0
p16(0x18, 0x60) # size of optional header
p16(0x1A, 0x0103) # characteristics
# 可选头 @0x1C:
p16(o, 0x10B) # PE32 magic
p32(o+16, 0x7C) # entry -> 33 C0 C3
p32(o+32, 4); p32(o+36, 4) # SectionAlignment=FileAlignment=4
p16(o+68, 2) # subsystem = GUI
# 入口代码
f[0x7C:0x7F] = b'\x33\xC0\xC3' # xor eax,eax ; ret9.2 make_pe_win64.py(核心)
# DOS头(64) + PE32+可选头(0x88) + 一个 .text 区段
# 代码起点被 FileAlignment=0x80 对齐到 0x180
code_off = align_up(header_len, 0x80) # -> 0x180
f += code_bytes # 33 C0 C310. 结论与展望
在当前 Windows 11 (26100) 上:
- 32 位 i386 PE 的最小可运行文件 = 268 字节,这是加载器硬性长度下限(267 拒 / 268 过)。
- 64 位 AMD64 PE 的最小可运行文件 = 387 字节,由“有可执行区段 +
FileAlignment≥0x80+ 可选头≥0x88”三条硬约束锁定。
- “最小”必须有约束空间的定义:换位宽、换 OS 版本、换“要不要真功能”,答案都会变。这也正是这类最小黑客题最好玩的地方。
- 用实验逼近边界很重要:规格书不会告诉你
267和268的界线,加载器会。
展望:
- 如果把“真功能”(比如弹 MessageBox)考虑进来,268 那种能弹窗的版本已经有人做到;但必然因为引入 user32 导入而显著变大。
- 若换一台更老的 32 位 Windows,长度下限可能更低(旧加载器没有 268 这个门)。
- 是否能在 64 位找到非常规手段绕过“必须有可执行区段/严格对齐”——我目前的多次尝试都被 216/193/0xC000007B 拒绝,尚未找到突破口,但也未穷尽。
11. 参考与延伸阅读
- TinyPE-on-Win10 — 268 字节 Win10 消息框(ayaka14732)
- What is the smallest possible Windows (PE) executable? — Stack Overflow
- Why does the Windows PE format have separate tables for import names and addresses? — The Old New Thing
如果你喜欢这类“把格式压到物理极限”的逆向/底层内容,欢迎交流。
评论区
还没有人评论