用 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-bit268 字节xor eax,eax; ret运行退出码 0，且这是当前加载器硬性长度下限64 位 AMD64 (PE32+)64-bit387 字节同上运行退出码 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 / … / 0x01REJECT(216)ERROR_EXE_MACHINE_TYPE_MISMATCH可选头降到 0x84ERROR 193镜像异常可选头降到 0x80 及以下REJECT(216)同上SectionAlignment 提为 0x100 / 0x200ERROR 193必须等于 FileAlignment入口点指到任意区段之外RUN 0xC000007BSTATUS_INVALID_IMAGE_FORMAT，入口必须落在可执行区段内区段与头重叠 / 入口指向头部ERROR 193 或 0xC000007B64 位禁止“零区段入口”于是 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) 零区段 + 头部当代码：不需要单独的 .texti386 加载器允许入口点直接落在“文件被完整映射出来的头部区”里执行。于是我把 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 是不是还能再压”，我对这个布局做了最干净的实验——逐字节裁剪文件尾部：文件长度结果266REJECT(216) ERROR_EXE_MACHINE_TYPE_MISMATCH267REJECT(216)268✅ 运行，退出码 0269✅ 运行也就是说，哪怕我把尾巴上全是零的字节一个个删掉，只要总长少于 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必须 ≥ 0x8064 位加载器对文件对齐要求更严DOS 头可用 e_lfanew=4 压到 4 字节需标准 64 字节 DOS 头32 位保留的历史兼容行为区段0 个（头部即代码）必须 ≥1 个64 位加载器禁“零区段入口”最终大小268387上面 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 PEpe_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 OverflowWhy does the Windows PE format have separate tables for import names and addresses? — The Old New Thing如果你喜欢这类“把格式压到物理极限”的逆向/底层内容，欢迎交流。