简介:PEiD(侦测未知壳加强版)是面向逆向工程师与恶意代码分析人员的查壳工具,用于识别可执行文件所用的壳类型、定位OEP入口,并应对未知或新型加壳保护。压缩包共45个文件,核心为PEiD.exe主程序,配套大量DLL插件(覆盖特征扫描、脱壳辅助、资源编辑、OEP查找、校验修复等功能)、Borland运行时BPL组件、txt签名与用户自定义数据库,以及以h/c形式提供的插件开发接口文件,整体约2.48MB,目录与插件结构清晰,便于直接运行和后续扩展。已有234人学习下载。该加强版在原版基础上扩充未知壳特征库与高级扫描策略,内含多种功能插件和SDK示例,既可快速完成日常查壳分析,也能为研究PEiD插件机制、编写私有签名规则提供可参考的实现样例,对恶意样本识别与软件保护机制研究具有实用价值。
1. 老工具的新活法:PEiD(侦测未知壳加强版)在解决什么问题
把样本拖进 PEiD,界面回你一句 "Nothing found",后面跟着一个从没见过的节区名。PEiD(侦测未知壳加强版)要解决的,不是某个新版本的壳库更新,而是老工具根子上的盲区:传统 PEiD 的“侦测”本质是拿已知壳的特征码去比对,匹配不上就是未知壳,再老的版本也拿它没办法。加强版的思路是把检测信号从“特征码”扩展到熵值、节区权限、入口点统计特征这些不依赖已知库的维度,让“查不出”变成“能画像”。这条路线适合三类人:手上常过陌生样本的逆向分析者、要给内部系统做加壳基线扫描的安全工程师,以及想搞懂查壳原理而不是只会点按钮的初学者。接下来从 PEiD 的老逻辑讲起,再给一套能跑的加强检测实现。
2. 传统 PEiD 的查壳逻辑:入口点特征码、Deep Scan 与结构盲区
2.1 入口点特征码:PEiD 最依赖的“指纹比对”
PEiD 读 PE 文件时,第一步是拿到 PE 头里的 AddressOfEntryPoint,也就是程序入口点的 RVA。然后它把这个入口点附近的几十个字节抠出来,和特征库 userdb.txt 里记录的签名逐字节比对。特征库里的每一条记录,大致是“壳名 + 偏移位置 + 一串字节序列”的结构,字节序列里用??表示通配,示意如下:
[UPX 2.9x(示意)] EP0: 60 BE ?? ?? ?? ?? 8D BE ?? ?? ?? ?? 8D 9E ?? ?? ?? ?? 57 83 CD FF这条规则的含义是:在入口点偏移 0 的位置,第一个字节必须是 0x60(对应 32 位下的 pushad 指令),后面若干字节允许任意值,但整体骨架固定。为什么 UPX、ASPack 这类壳能被秒查?因为加壳工具生成的入口胶水代码几乎是模板化生产,几代版本都改不掉开头的 pushad、取址、跳转套路。特征码匹配的优势是快、准、占用资源少,缺点是脆弱——只要壳作者在入口处插入一个花指令字节,整条特征码就全部失配,界面立刻回到 Nothing found。这也是老 PEiD 使用者最常遇到的情况:拿变种壳样本去查,得到的是一个干净得让人心里发毛的结果。
2.2 未知壳为什么会让老版本“失声甚至误报”
特征库里没有签名,PEiD 就无话可说。所谓未知壳,不单指新发布的商业壳,还包括三种很常见的变体:第一种是壳作者在模板上做了字节级改动,比如在入口处插花指令、调换两条指令的顺序;第二种是自动化加壳工具每次生成时随机化入口代码;第三种是那些主动反查壳工具的壳,它们会检测自身是否被调试器或查壳软件打开,故意把入口特征伪装成普通程序的样子。更麻烦的是,老版本 PEiD 的特征库停更了很多年,新出现的壳根本不在库内,库里留下的老签名还会造成另一种翻车——匹配到旧特征的一个子串,于是报出一个完全错误的名字。
| 老 PEiD 能给出的信号 | 获取方式 | 面对未知壳时的表现 |
|---|---|---|
| 已知壳特征签名 | userdb.txt 逐字节匹配 | 变种一个字节不匹配就失声 |
| 入口点代码片段 | 入口点扫描 | 花指令、VM 化后完全失真 |
| 节区名(UPX0、.aspack 等) | 读取节表 | 节名可伪造,一句话绕过 |
| Overlay 附加数据 | 文件尾部检测 | 数字签名也会触发,只能当参考 |
这张表基本就是老 PEiD 的全部家底:特征库、入口点代码、节区名、附加数据。前两个是“已知知识”驱动,遇到新壳就归零;后两个只是提示信号,没法单独下结论。曾经有经验的分析者不只看壳名,而是看入口点落在哪个节区、节区权限是否可写可执行,这些从 PE 头里直接读出来的结构信息,反而比特征库更耐用。这也就是加强版真正要补的位置:把结构信息变成可量化的检测信号。
2.3 Deep Scan 与其他辅助信号:老版本仅有的立体手段
PEiD 界面里其实还有一个 Deep Scan 模式,中文习惯叫深扫。它和普通扫描的区别在于:普通扫描只看入口点附近的特征,Deep Scan 会在整个文件范围内分段扫描,能照顾到入口点不在 PE 头标注位置的少数壳。但深扫的本质还是拿特征库去匹配,扫的区间变大,库还是那个库。除此之外,PEiD 会在结果面板里展示入口点所在节区名、入口点文件偏移、Overlay 长度。这些原始信息基本没人细看,但恰恰是后面做增强检测的起点。老 PEiD 给的是“已知特征库 + 少量结构提示”,加强版补的是“结构统计 + 信息熵 + 指令分布”,后者不依赖任何已知壳的签名,理论上能覆盖所有变种和未知壳。
3. 给未知壳画像:熵值、节区权限与入口点统计特征
3.1 区段熵值:压缩壳的“高熵”痕迹
信息熵衡量一段数据的混乱程度,计算时以字节为单位,统计每个字节值出现的概率,套用香农熵公式:H = -Σ p(x)·log2(p(x))。8 位字节的理论最大熵是 8,数据越接近随机分布,熵值越高。正常编译出来的 C/C++ 代码,操作码分布有明显的偏向性,区段熵通常在 5.0 到 6.5 之间;而 UPX、ASPack 这类压缩壳会把原始代码压缩后再加一层解压逻辑,压缩后的数据近似随机,熵值经常冲到 7.0 以上。这个信号之所以可靠,是因为加壳的本质就是把原始代码变成“不可直接执行”的压缩或加密数据,这个变换过程必然抬高熵值。
| 区段类型 | 常见熵值范围 | 备注 |
|---|---|---|
| 普通 C/C++ 代码节区 | 5.0 ~ 6.5 | 编译器生成的指令分布 |
| 压缩壳数据区段 | 7.0 ~ 7.8 | UPX 等压缩后近似随机 |
| 加密或 VM 字节码区段 | 7.5 ~ 8.0 | 接近均匀分布 |
| 导入表、资源数据 | 4.0 ~ 6.0 | 原始字节流较规则 |
要注意一点:熵值只看代码节区,别把资源节、签名段、Overlay 拉进来算。很多加壳程序会把资源单独存放,资源数据的熵本来就不低,混在一起算会把信号抹平。实操里我只看 EP(入口点)所在节区的熵,或者所有带代码标志的节区分别算,哪个高取哪个做参考。
3.2 入口点所在节区与节区权限:加壳逃不掉的结构改动
加壳必然要改动 PE 结构,因为壳需要给自己腾出空间来存放解压代码和压缩后的原始程序。这种改动在节表层面会留下几个很难消除的痕迹。第一,EP 通常被指向新加的节区,而且这个节区往往是文件末尾的最后一个节;正常程序入口应该落在 .text 或等价的代码节里,很少出现在最末节。第二,壳为了在运行时解压数据,节区权限经常会设置成可读可写可执行同时存在,也就是 RWX;正常编译出来的代码节一般是 R-X,数据节是 R-W,R 和 W 和 X 三项同时亮起是强信号。第三,节区名会变得可疑,比如 UPX0、UPX1、.aspack,但聪明的壳会伪造节名,所以节名只能当辅助,不能当主证据。
这三个结构信号的特征码无法覆盖,因为特征码只匹配字节序列,不去管节区权限和 EP 位置。而且壳无论怎么变种,总要给自己保留一个可写可执行的内存区域,否则解压代码无处安放。就算壳作者把节区名改成 .text,把权限伪装成 R-X,运行时一旦要写内存就会露馅。所以结构信号在对抗变种壳时,比特征码稳定得多。
3.3 入口点代码统计:pushad、call 与花指令的软证据
特征码匹配要求“逐字节精确命中”,变种壳只要插入一条花指令就全盘失配。换个思路:不去匹配精确序列,而是统计入口点附近指令的出现频次。加壳工具的入口胶水代码再怎么变,核心动作是固定的——保存现场、取当前地址、跳转到解压循环、填充花指令。对应到字节上就是 pushad(0x60)、pushfd/pushfq(0x9C)、call(0xE8)、短跳 jmp(0xEB)、nop(0x90)这类操作码的高频出现。写一小段代码就能算:
def ep_opcode_hits(raw: bytes, ep_offset: int, window: int = 128) -> dict: chunk = raw[ep_offset:ep_offset + window] targets = { "pushad(0x60)": bytes([0x60]), "pushfd(0x9C)": bytes([0x9C]), "call(0xE8)": bytes([0xE8]), "jmp(0xEB)": bytes([0xEB]), "nop(0x90)": bytes([0x90]), } return {name: chunk.count(op) for name, op in targets.items()}这段代码的思路是把 EP 附近的窗口切成固定长度,然后数每个操作码在这个窗口里出现了几次。窗口默认 128 字节,太小了统计波动大,太大了会被无关代码稀释。逻辑上要注意:统计的是“频次”而不是“序列”,因为变种壳会调整指令顺序,但操作码的整体分布很难改干净。一个正常程序的入口点,call 和 nop 可能有一些,但 pushad、pushfd 同时高频出现的情况极少;而 UPX 这类 32 位壳的入口,几乎必然有 pushad 开场的固定套路。另外有一个额外信号:如果 EP 窗口里的字节本身熵值就高于 7.0,说明入口代码是压缩或加密过的,这比任何指令统计都更直接——壳的入口都藏起来了,当然可疑。
4. 落一个能跑的加强版检测器:解析 PE、算熵、组合打分
4.1 用 pefile 解析 PE 结构,拿到 EP、节区与权限
前面铺垫了三个信号,现在做成一个可复用的 Python 检测器,依赖只有一个第三方库 pefile。先写加载函数,把 PE 头、入口点、节表、节区权限一次取全:
import math import pefile def section_entropy(data: bytes) -> float: if not data: return 0.0 freq = [0] * 256 for b in data: freq[b] += 1 total = len(data) h = 0.0 for c in freq: if c: p = c / total h -= p * math.log2(p) return h def load_pe_info(path: str): pe = pefile.PE(path, fast_load=True) ep_rva = pe.OPTIONAL_HEADER.AddressOfEntryPoint ep_offset = pe.get_offset_from_rva(ep_rva) bitness = pe.OPTIONAL_HEADER.Magic # 0x10b 表示 32 位,0x20b 表示 64 位 sections = [] for sec in pe.sections: name = sec.Name.rstrip(b"\x00").decode("utf-8", "replace") chars = sec.Characteristics sections.append({ "name": name or "(无名节区)", "entropy": section_entropy(sec.get_data()), "is_code": bool(chars & 0x00000020), "readable": bool(chars & 0x40000000), "writable": bool(chars & 0x80000000), "executable": bool(chars & 0x20000000), "last": False, }) sections[-1]["last"] = True return pe, ep_rva, ep_offset, bitness, sections这段代码的要点在pe.get_offset_from_rva(ep_rva),它把入口点从 RVA 转成文件偏移,这一步是后面所有字节级分析的前提。很多人手写转换时直接用节表的 PointerToRawData 加偏移,遇到 PE 头里有 SectionAlignment 和 FileAlignment 不一致的情况就会算错,统一交给 pefile 处理最稳。fast_load=True只解析头信息,不加载区段数据,性能开销小。sec.get_data()直接返回该节区的原始字节,正好喂给熵值计算。最后把last标记打在最后一个节区上,用来判断 EP 是否落在文件末尾节区。调用方记得在finally里执行pe.close(),避免文件句柄泄漏。
4.2 区分 32/64 位:入口点指令窗口统计的完整版
上一章的统计代码是 32 位视角,直接用在 64 位程序上会出问题:0x60 在 64 位模式下并不是有效的 pushad 指令,统计它没有意义。完整版需要根据 PE 头里的 Magic 字段分流:
def ep_opcode_hits_full(raw: bytes, ep_offset: int, bitness: int, window: int = 128) -> dict: chunk = raw[ep_offset:ep_offset + window] if bitness == 0x10b: # 32 位 PE targets = { "pushad(0x60)": bytes([0x60]), "pushfd(0x9C)": bytes([0x9C]), "call(0xE8)": bytes([0xE8]), "jmp(0xEB)": bytes([0xEB]), "nop(0x90)": bytes([0x90]), } else: # 64 位 PE targets = { "pushfq(0x9C)": bytes([0x9C]), "call(0xE8)": bytes([0xE8]), "jmp(0xEB)": bytes([0xEB]), "nop(0x90)": bytes([0x90]), } return {name: chunk.count(op) for name, op in targets.items()}参数说明:window决定统计窗口长度,128 字节是默认值,对大多数壳足够;如果样本入口处有大量花指令填充,可以调大到 256,让统计更平滑。bitness直接取Magic值比较,不需要额外解析。逻辑说明:64 位下不再统计 pushad,因为 0x60 在 x64 下是无效指令,正常程序入口不该出现它;如果 64 位样本的 EP 窗口里出现了大量 0x60,那这本身就是异常信号,可以直接拉高嫌疑分。另外补充一个判断:如果chunk本身的熵值大于 7.0,说明入口代码处于加密或压缩状态,这种“入口被藏起来”的情况比任何指令统计都更有说服力。
4.3 组合打分与报告输出:把三个信号合成一个结论
三个信号单独看都有误差,组合起来才能用。我的做法是给每个信号分配权重,算出总分后按阈值判定。默认权重如下:
| 信号 | 默认权重 | 打分规则 |
|---|---|---|
| EP 所在节区熵值 | 0.35 | 熵 >= 7.0 给 1.0;5.5 ~ 7.0 按比例;低于 5.5 给 0 |
| EP 落在末尾节区 | 0.30 | 是给 1.0,否则 0 |
| 节区 RWX 权限 | 0.20 | EP 节区同时可写可执行给 1.0 |
| 入口点指令统计 | 0.15 | 命中 2 类以上指令才给 1.0 |
def find_ep_section(sections, ep_offset): for sec in sections: start = sec["raw"] if "raw" in sec else 0 # 简化判断:上面 load_pe_info 未保存 raw 范围,这里补一个 return None def analyze(path: str): raw = open(path, "rb").read() pe, ep_rva, ep_offset, bitness, sections = load_pe_info(path) try: ep_sec = None for sec in pe.sections: start = sec.PointerToRawData size = max(sec.Misc_VirtualSize, sec.SizeOfRawData) if start <= ep_offset < start + size: ep_sec = sections[pe.sections.index(sec)] break sig = {"entropy": 0.0, "ep_last": 0.0, "rwx": 0.0, "opcodes": 0.0} if ep_sec: ent = ep_sec["entropy"] sig["entropy"] = 1.0 if ent >= 7.0 else max(0.0, (ent - 5.5) / 1.5) sig["ep_last"] = 1.0 if ep_sec["last"] else 0.0 sig["rwx"] = 1.0 if ep_sec["writable"] and ep_sec["executable"] else 0.0 hits = ep_opcode_hits_full(raw, ep_offset, bitness) hot = [v for v in hits.values() if v >= 2] sig["opcodes"] = min(1.0, len(hot) / 3.0) weights = {"entropy": 0.35, "ep_last": 0.30, "rwx": 0.20, "opcodes": 0.15} total = sum(weights[k] * sig[k] for k in weights) return total, sig, ep_sec, hits finally: pe.close()这段组合代码里,ep_sec的定位用的是文件偏移落在节区原始数据范围内这个规则,避免引入复杂的 RVA 换算。sig字典存四个信号的归一化分值,每个都压到 0 到 1 之间。权重总和是 1.0,总分也自然落在 0 到 1 之间。判定经验值:总分大于 0.6 判定为高嫌疑,0.4 到 0.6 之间是中嫌疑,低于 0.4 暂不处理。输出时把 EP 节区名、熵值、RWX 状态、指令统计一起打出来,方便人工二次确认。这一步做完,一个不依赖任何已知壳签名的“未知壳侦测器”就能用了。
5. 避坑指南:阈值调优与误报排查的五个常见问题
5.1 高熵误报:Qt 和 Electron 程序为什么总是“高嫌疑”
现象:把内部开发的一个 Qt 桌面程序或 Electron 打包的程序扔进检测器,几乎所有样本都报“加壳嫌疑高”,得分普遍在 0.7 以上。
原因:Qt 程序的大量逻辑集中在编译后的代码段里,部分代码段经过编译器优化后字节分布比较散,熵值本身就接近 7.0;Electron 程序体内嵌了 asar 打包数据和 V8 引擎快照,这些数据段的熵值天然很高。如果检测器对所有节区都算熵,或者只看全文件平均熵,必然误报。
解决:第一,熵只看 EP 所在节区,不要看全文件;第二,排除明显的资源节、数据节,只保留带代码标志(IMAGE_SCN_CNT_CODE)的节区参与判断;第三,把熵信号的高阈值从 7.0 提到 7.3。我实际用下来,正常程序 EP 代码节的熵很少超过 7.2,7.3 这条线能把绝大多数误报挡掉。
5.2 变种壳漏报:改一个字节就让旧特征彻底失声
现象:拿 UPX 加壳的样本,手动修改入口点的一个字节后,老 PEiD 立刻显示 Nothing found,加强版检测器的得分也掉到 0.3 左右,明明壳还在。
原因:特征码匹配要求逐字节精确命中,一个字节不匹配就整体失败。加强版的指令统计虽然不依赖精确序列,但如果你把统计窗口缩得太小,或者只统计少数几个操作码,变种样本一样能绕过。
解决:统计窗口不能低于 128 字节,最好同时对 256 字节窗口做一次交叉验证;操作码列表里加入 0xEB(短跳)和 0x90(nop)这类花指令常用的字节,变种壳特别喜欢用它们填充入口。另外坚持“多个信号同时看”的原则,EP 节区熵和 RWX 权限这两个结构信号不会因为入口改动而消失,只要 EP 还落在末尾节区,总分就不会太低。
5.3 低熵漏报:VM 壳和混淆壳的熵值并不总是很高
现象:某商业壳加壳的样本,全文件熵只有 6.2,EP 节区熵 6.8,检测器判定为“低嫌疑”,但实际是个保护强度很高的 VM 壳。
原因:VM 壳把原始指令虚拟化成自定义字节码,这些字节码的分布和普通代码差不多,不会像压缩壳那样出现 7.5 以上的高熵;混淆壳则会把代码展开成大量等概率的分支块,熵值同样被拉低。把熵当唯一指标,就会漏掉这类壳。
解决:对熵值偏低的情况,补充两个替代信号——导入表异常和 EP 代码风格。VM 壳通常会抽空导入表,把 API 调用集中到自己维护的一张表里,导致导入函数数量少得可疑;EP 处如果直接跳到一段类似 dispatcher 的分发循环,也是强信号。我在权重表里给熵值降权到 0.25,给 EP 节区位置升权到 0.35,对 VM 壳的检出率明显更好。
5.4 偏移换算错误:把 RVA 当文件偏移用的经典翻车
现象:用第 4 章的脚本检测一个壳样本,EP 明明显示 0x1000,打印出来要分析的字节却落在完全无关的数据区,指令统计全是 0。
原因:PE 头里的 AddressOfEntryPoint 是 RVA,不是文件偏移。RVA 是相对内存基址的虚拟地址,必须减去所在节区的 VirtualAddress,再加上 PointerToRawData,才能得到文件里的真实偏移。这里换算错一位,后面所有分析全歪。
解决:不要手算,统一用pe.get_offset_from_rva(ep_rva)。如果你实在要手算,也要先确认节区的 VirtualAddress 和 PointerToRawData 之间的差值,再用ep_rva - VirtualAddress + PointerToRawData计算。调试时先打印 EP 所在节区的名字,如果 EP 显示落在 .text 但换算后偏移指向了 .rsrc,那一定是换算写错了。
5.5 运行时库误报:带运行时库的开发工具程序被当成壳
现象:某种带运行时库的开发工具编译出来的程序,本身没加壳,但检测器报“加壳嫌疑高”,查看节区发现有一个高熵的运行时数据段,指令统计里也出现了疑似壳特征的操作码。
原因:这类程序会把运行时库一并打包进可执行文件,运行时库的初始化代码有自己的入口套路和大量间接跳转,从统计特征上看和壳的初始化代码高度相似;再加上运行时数据段熵值偏高,三个信号里中了两个,就被误报了。
解决:对这类样本做“双特征”判断——只有同时满足“EP 节区熵高”和“EP 落在末尾节区”才给高嫌疑,单纯的高熵加指令统计最多给到中嫌疑。另外建立一份白名单节区名,比如运行时库自带的固定节区名直接跳过熵计算。这种误报很难彻底消除,因为信号层面确实和壳太像,只能靠阈值收紧和人工复核兜底。
6. 验证与进阶技巧:用样本集校准阈值,用 OEP 熵变化确认
检测器写完不算完,阈值定多少直接决定好不好用。我的习惯是先收集一套小样本集:10 个已知加壳样本(覆盖 UPX、ASPack、某种商业壳的变种)加上 10 个干净程序,跑一遍打分,再根据结果调权重和阈值。比如把结果导出成 JSON,写几行代码统计漏报和误报:
import json results = json.load(open("results.json")) packed = [r for r in results if r["label"] == "packed"] clean = [r for r in results if r["label"] == "clean"] tp = sum(1 for r in packed if r["score"] >= 0.6) fp = sum(1 for r in clean if r["score"] >= 0.6) print(f"召回率 {tp}/{len(packed)}, 误报 {fp}/{len(clean)}")这一步很值得做,我最早把阈值拍脑袋定在 0.6,结果拿一套真实样本一跑,误报率三成,被打脸打得很疼。后来养成的习惯是每次调完阈值,都要把样本集重新跑一遍,保证召回率不掉到 90% 以下才敢给别人用。
还有一个手工确认的进阶技巧:加壳程序运行时,壳会先解压或解密,然后跳转到原始入口点 OEP。用调试器在 OEP 处下断,把内存 dump 下来,再把这堆内存数据扔进检测器,你会发现原来的高熵节区熵值从 7.x 回落到 5.x,RWX 权限也消失。这个“熵值回落”现象是加壳最硬的证据,动态分析时屡试不爽。没条件动态分析时,就退而求其次,对比 EP 节区熵和所有代码节区的平均熵,前者明显高于后者,也值得手动跟进。
这套加强版方案从一开始就不是为了替代老 PEiD,而是给老工具补上“特征库之外”的视角。特征码负责快准狠地识别已知壳,熵值和结构信号负责兜住未知壳和变种壳。两条腿走路,查壳这件事才算真正落地。希望帮到你。
本文还有配套的精品资源,点击获取