简介:PEiD(侦测未知壳加强版)是一套面向逆向工程初学者与安全分析人员的查壳工具合集,重点解决可执行文件加壳类型难以识别、未知壳特征不足的问题,可用于恶意样本初筛、软件保护机制研究与PE文件分析。压缩包共45个文件,以36个dll插件为主,配合exe主程序、bpl运行时库、ini与txt配置/签名数据及少量头文件和示例源码,整体约2.48MB,插件与签名库的目录结构便于按功能扩展。已有235人学习下载。它的价值在于把基础查壳、增强签名识别与多种插件能力集中打包:既可通过内置与更新签名判断常见壳,也能借助高级扫描、PE重建、导入表修复、资源查看等模块辅助后续分析;附带的说明与配置数据还便于理解插件加载、特征库维护和未知壳侦测思路,适合作为安全实验与逆向练习的常备工具箱。
1. 拿到一个陌生 exe,PEiD查壳永远是第一步
有人丢给你一个样本,或者自己打的包在客户机器上体积翻了三倍、导入表里只剩两个 API——这些信号都指向同一件事:被加壳了。PEiD查壳 的价值就在这几秒:不用开调试器,把文件拖进去,入口点 RVA、EP 落在哪个节、前几十个字节、签名库能不能对上号,一次摆到眼前。经典版停更多年,流传的 PEiD加强版 主要补了两块:签名库扩容,以及未知壳侦测的处理方式——对不上签名时不再只丢一个 Nothing found,而是把节区名、EP 位置、异常字节一起给你,让人有得判。这套东西适合三类人:逆向入门想搞清加壳与入口点关系的、做样本初筛需要快速分流的、排查自家软件被第三方打包改坏的。它不是脱壳机,只负责把「有没有壳、大概是什么壳」在十秒内定下来。
2. 查壳器在查什么:PE 头、节表、EP 字节与签名的四层判定
很多人把查壳理解成「比对一条特征码」,这个认知会在实战里吃大亏。签名匹配只是最后一层,而且是最容易被骗的一层:改一个字节、把 EP 挪两行、换个跳转指令,签名立刻失效,但壳还是那个壳。加强版把前面几层的信息也摊开给你看,就是为了在签名失效时还能下判断。这一章把这四层拆开讲清楚,后面写签名、做批量脚本才有依据。
2.1 从 DOS 头走到入口点:一次查壳要读的四个结构
PE 文件的布局是固定套路:开头 64 字节的 DOS 头,e_lfanew(偏移 0x3C)指向真正的 PE 签名PE\0\0;签名后紧跟 20 字节的 COFF 文件头,里面记着目标机器类型、节数量、可选头大小;再往后是可选头,32 位程序一般是 224 字节,AddressOfEntryPoint就在这里;最后是节表,每个节 40 字节,描述这一段代码/数据在文件里和内存里各自的起点与长度。
查壳工具读的无非是这几处,我把关键字段和它在查壳时的用途列成表:
| 结构 | 关键字段 | 查壳时用来干什么 |
|---|---|---|
| COFF 文件头 | Machine、NumberOfSections、TimeDateStamp | 判断 32/64 位;节数量异常多或异常少都可疑 |
| 可选头 | AddressOfEntryPoint、ImageBase、SizeOfImage、Subsystem | 定位 EP;镜像大小与节总和对不上说明有附加数据 |
| 节表 | Name、VirtualAddress、VirtualSize、SizeOfRawData、PointerToRawData、Characteristics | 看节名、看 EP 落在哪个节、看节是否可写可执行 |
| 数据目录 | Import Directory、Resource Directory | 导入表只剩个位数函数、资源节被抹掉,都是加壳信号 |
节表的Characteristics值得单独说一句:正常编译器产出的代码节是「可执行 + 可读」,数据节是「可读 + 可写」。如果某个体积巨大的节同时具备可写和可执行属性,那基本可以确定它要在运行时往里写解压后的代码,这是壳的典型手法。
2.2 RVA 到文件偏移的换算,以及 EP 落在节外意味着什么
AddressOfEntryPoint存的是 RVA(相对虚拟地址),不是文件偏移。要在文件里读到 EP 处的字节,必须做一次换算:找到包含该 RVA 的节,用PointerToRawData + (RVA - VirtualAddress)得到文件偏移。这个换算看着简单,但它是后面所有坑的源头——一旦 EP 落在任何一节的范围之外,换算就没有结果,工具读不到字节,签名自然也就匹配不上。
import pefile def rva_to_offset(pe, rva): """把 RVA 换算成文件偏移;换算不到返回 None""" for s in pe.sections: start = s.VirtualAddress # VirtualSize 是内存中实际大小,SizeOfRawData 是文件中大小,取大的更保险 size = max(s.Misc_VirtualSize, s.SizeOfRawData) if start <= rva < start + size: return s.PointerToRawData + (rva - start) return None pe = pefile.PE("sample.exe", fast_load=True) ep_rva = pe.OPTIONAL_HEADER.AddressOfEntryPoint off = rva_to_offset(pe, ep_rva) print(hex(ep_rva), off, pe.get_memory_mapped_image()[ep_rva:ep_rva+16].hex())逻辑说明:遍历节表,用VirtualAddress作区间左端点、max(VirtualSize, SizeOfRawData)作长度,命中区间就按公式换算。参数方面,fast_load=True只解析头,不解析导入导出表,速度快很多,批量扫描时必开;get_memory_mapped_image()返回的是按内存布局展开的字节串,用它取 EP 字节比直接切文件偏移更不容易出错,因为壳经常让SizeOfRawData小于VirtualSize,文件里其实没有对应数据。
如果rva_to_offset返回None,说明 EP 不在任何已声明节内。这种情况在正常编译器产出的程序里几乎不会出现,在加壳样本里却很常见——有的是把 EP 挪到了最后一节的尾部空隙,有的是故意把节表改乱让分析工具读不到。碰到这种,工具界面上 EP Section 一栏会是空的,这本身就是结论:不是「没查出来」,是「入口点被刻意藏了」。
2.3 签名匹配:EP 字节 + 通配符 + ep_only
签名库的本质是一堆「字节串 + 通配符」的集合。每条签名从 EP 处(或全文件)取一段字节,逐位比对,??表示该位置任意值。ep_only = true表示只在入口点位置匹配,false表示在整个文件中搜索——后者能抓到一些把特征代码藏在非入口处的壳,代价是扫描变慢、误报变多。
为什么要有通配符?因为壳的入口代码里混着大量随版本变化的量:相对跳转指令E8/E9后面跟的 4 字节偏移、push的立即数、重定位后的绝对地址。这些位置每个版本都不一样,写死就没法通用了。一条合格的签名通常是「前 8 到 16 字节精确 + 中段若干通配 + 尾部精确锚点」,靠两头固定、中间放行的方式提高容错。
这里有个常被忽略的点:签名匹配的输入不是原始文件字节,而是「按内存布局展开后的 EP 处字节」。同一个文件用文件偏移去切和用 RVA 去切,在正常程序上结果一样,在壳上经常不同。自己写脚本验证签名时,这一点必须对齐,否则你复现出来的结果和工具永远差几个字节。
2.4 用 pefile 复刻一次四层判定
把上面三层串起来,就是一次完整的查壳判定。下面这段脚本按「节区名 → EP 位置 → 签名 → 熵值」的顺序输出,可以直接拿去当批量初筛的基础。
import pefile, math from collections import Counter def entropy(data: bytes) -> float: """香农熵,0~8,越高越接近压缩/加密数据""" if not data: return 0.0 c = Counter(data) n = len(data) return -sum((v / n) * math.log2(v / n) for v in c.values()) def quick_check(path): pe = pefile.PE(path, fast_load=True) ep = pe.OPTIONAL_HEADER.AddressOfEntryPoint result = {"path": path, "ep_rva": hex(ep)} # 第一层:EP 落在哪个节,节名是什么 sec_name, delta = None, None for s in pe.sections: start = s.VirtualAddress size = max(s.Misc_VirtualSize, s.SizeOfRawData) if start <= ep < start + size: sec_name = s.Name.rstrip(b"\x00").decode("latin-1", "replace") delta = ep - start raw = pe.get_data(start, min(size, 0x1000)) result["ep_section_entropy"] = round(entropy(raw), 3) break result["ep_section"] = sec_name result["ep_offset_in_section"] = delta # 第二层:EP 处前 32 字节 result["ep_bytes"] = pe.get_memory_mapped_image()[ep:ep + 32].hex() # 第三层:节名清单,人眼扫一遍比什么都快 result["sections"] = [ s.Name.rstrip(b"\x00").decode("latin-1", "replace") for s in pe.sections ] # 第四层:整体熵,>7.2 基本可以按压缩/加密对待 result["file_entropy"] = round(entropy(pe.__data__), 3) return result逻辑说明:先取 EP,再在节表里定位它属于哪一节,顺便算这一节的熵;然后取 EP 处 32 字节;最后列节名、算整文件熵。参数上,节样本人为限制到0x1000是为了避免大节拖慢速度,熵值对样本量不敏感,4KB 足够看出趋势。
结果怎么读:ep_section是UPX0/UPX1/.aspack/.themida这类名字,直接定性;ep_section是None,说明 EP 在节外;ep_offset_in_section接近 0,说明 EP 就在节首,正常编译器产物也常这样,不能单独作为判据;ep_section_entropy超过 7.2 且节名正常,反而是最需要警惕的组合——名字伪装成.text、内容是压缩数据,这是近几年常见的伪装手法。整文件熵超过 7.2 而节熵正常,多半是附加数据(overlay)导致的,得单独看尾部。
3. 上手流程:从环境隔离到批量报告落地
工具本身双击就能跑,但真正决定效率的是前面十分钟的环境准备和后面半小时的结果整理。散着查十个文件还行,查两百个就得靠流程。这一章按我平时的操作顺序走一遍。
3.1 虚拟机快照与工作目录约定
查壳的对象多半来路不明,PEiD 本身虽然只是静态解析、不执行样本,但它会加载签名库、可能触发文件关联,稳妥做法还是放进虚拟机。开一个干净快照,关掉网络共享和剪贴板同步,样本目录和工具目录分开,别混在一起。
目录我一般按下面这样摆,好处是后面写批量脚本时路径好拼:
| 目录 | 用途 | 备注 |
|---|---|---|
tools/peid/ | 查壳工具本体与签名库 | 整个目录只读,不往里写东西 |
tools/peid/userdb.txt | 自建签名,单独备份一份 | 升级或换机器时直接拷回去 |
samples/inbox/ | 新样本落地目录 | 只放待查文件,不建子目录 |
samples/reports/ | 工具导出的原始报告 | 按批次日期建子目录 |
samples/verified/ | 人工复核过的样本 | 文件名前缀标注结论 |
签名库的备份这件事,吃过亏的人都会做。加强版的签名库是纯文本,改坏一次就得从头找回来;inbox和verified分开,则是为了避免重复扫描——同一个文件扫三遍,报告里出现三条结论,后面统计就乱了。
3.2 单文件检测的标准六步
单个文件我固定走六步,看起来啰嗦,但比漏看某一项之后回头重来要快:
- 先算哈希并记录。哈希是后面所有结论的索引,没有哈希的报告等于没写。
- 看文件大小和 PE 头里的
SizeOfImage差值,差值大说明有附加数据。 - 拖进工具,先看节名清单,再看 EP Section。
- 看签名命中结果,命中就记下签名名和签名库里的对应条目。
- 没命中,看 EP 处字节和节熵,按上一章的四层判定自己下结论。
- 把结论、哈希、判定依据写进一张表,未命中的标注「未知,待人工」。
第 1 步的哈希我习惯用脚本做,顺手把文件名规范化:
import hashlib, shutil, pathlib def intake(src: str, inbox: str = "samples/inbox"): """把样本复制进收件箱,按 sha256 前 16 位重命名,返回记录""" p = pathlib.Path(src) h = hashlib.sha256(p.read_bytes()).hexdigest() dst = pathlib.Path(inbox) / (h[:16] + p.suffix.lower()) shutil.copy2(p, dst) return {"sha256": h, "stored": str(dst), "size": p.stat().st_size}逻辑说明:先读全文件算 SHA256,再用前 16 位当文件名,避免同名覆盖,也方便后面按哈希反查。shutil.copy2保留时间戳,某些样本会利用时间戳做校验,改了可能影响行为分析,虽然查壳用不上,但保持这个习惯没坏处。参数上,src传绝对路径,inbox用相对路径时要注意脚本的工作目录。
第 2 步的差值计算可以直接加在上面的脚本里:pe.OPTIONAL_HEADER.SizeOfImage和文件实际大小的比值,超过 1.2 就去看 overlay 区。附加数据本身不是壳的证据,但很多安装包、自解压程序会带一大段附加数据,不区分清楚会把它们误判成加壳。
3.3 目录递归扫描与报告导出
样本攒到几十个之后就不该再手动拖了。工具的目录扫描功能一般在菜单里,勾上递归子目录,选好签名库,指定输出文件,让它自己跑。跑之前有两个细节要确认:一是签名库路径指向的是你改过的那份userdb.txt,不是安装目录里的默认库;二是输出格式选纯文本,别选带界面的实时刷新,几百个文件跑起来界面会卡住。
扫描本身不慢,慢的是后面的筛选。原始报告通常是「文件路径 + 结论 + 若干字段」的文本行,格式不固定,直接看很痛苦。我一般跑完立刻转成 CSV,用表格软件按结论列排序,先处理「命中高危壳」的那批,再看「未识别」的那批。
3.4 把 txt 报告转成可筛选的 CSV
报告格式各版本略有差异,稳妥做法是用正则抓「路径 + 结论」这两段,其余字段抓不到就留空,不要把脚本写得过于依赖具体排版。
import re, csv, pathlib # 抓取形如 "C:\path\a.exe -> UPX 3.0" 或 "path ... Nothing found" 的行 LINE = re.compile(r"^(?P<path>.+?\.(?:exe|dll|sys|ocx))\s*(?:->|:|\s{2,})\s*(?P<verdict>.+)$", re.IGNORECASE) def txt_to_csv(report: str, out: str): rows = [] for line in pathlib.Path(report).read_text(errors="ignore").splitlines(): m = LINE.match(line.strip()) if not m: continue rows.append({"file": m.group("path").strip(), "verdict": m.group("verdict").strip()}) with open(out, "w", newline="", encoding="utf-8-sig") as f: w = csv.DictWriter(f, fieldnames=["file", "verdict"]) w.writeheader() w.writerows(rows) return len(rows)逻辑说明:只抓两类信息,路径和结论,其余全部丢弃。errors="ignore"是因为报告里可能混着 ANSI 编码的文件名,硬解码会直接抛异常中断整批处理。输出用utf-8-sig,双击打开不乱码。返回行数用于对账——扫描了多少个文件,转换出来就该有多少行,差太多说明正则漏匹配了,得回头调。
转完之后我会加一列「人工结论」,分三档:确认加壳、确认未加壳、可疑。这一列才是后面真正有价值的资产,签名库的补充就是从「可疑」那批里长出来的。
4. 加强版的真正价值:userdb.txt 里的签名怎么写
工具自带的签名库再全,也覆盖不了你手上那批样本。加强版之所以叫加强版,一半功劳在签名库可编辑。这个文件是纯文本,格式简单到近乎简陋,但正因为简单,写错一个字符整条签名就静默失效,还不报错——这是最坑的地方。
4.1 一条签名的三个字段
签名条目由方括号开头的一行名字,加上后面若干键 = 值组成。字段不多,我列全:
| 字段 | 是否必填 | 取值 | 作用 |
|---|---|---|---|
[名称] | 必填 | 任意字符串,建议带版本 | 命中后显示的名字,也是你排错的唯一线索 |
signature | 必填 | 十六进制字节串,空格分隔,??通配 | 要比对的字节序列 |
ep_only | 选填 | true/false | 只匹配入口点,还是全文件搜索 |
三条规则必须记住:字节必须是两位十六进制,0不行要写00;分隔符是单个空格,多个空格某些解析器会认;ep_only不写默认按false处理,全文件搜索,速度慢且容易误报。名称重复不会报错,但后者会覆盖前者的显示,排查时容易看花眼。
4.2 从 EP 字节自动生成签名候选
手抄字节容易错,直接从样本里生成更靠谱。下面这段把 EP 处 32 字节打印出来,并把疑似变动的位置标成??:
import pefile # 常见需要通配的位置:跳转/调用的 4 字节相对偏移、push 立即数 WILDCARD_AFTER = {0xE8: 4, 0xE9: 4, 0x68: 4, 0xA1: 4} def gen_signature(path: str, n: int = 32) -> str: pe = pefile.PE(path, fast_load=True) ep = pe.OPTIONAL_HEADER.AddressOfEntryPoint data = pe.get_memory_mapped_image()[ep:ep + n] out, i = [], 0 while i < len(data): b = data[i] out.append(f"{b:02X}") if b in WILDCARD_AFTER: span = WILDCARD_AFTER[b] out.extend(["??"] * span) i += span + 1 continue i += 1 return " ".join(out) print(gen_signature("samples/inbox/xxxx.exe"))逻辑说明:按字节走,遇到E8/E9/68/A1这四类指令,把后面 4 字节直接替换成通配符。这四类覆盖了绝大多数随版本变化的量:相对调用、相对跳转、压栈立即数、绝对地址取值。参数n控制取样长度,32 字节是比较平衡的值——太短区分度不够,太长则壳一更新就失配。
生成的串可以直接贴进userdb.txt,但别急着收工。这只是候选,还得做减法:把其中明显属于通用编译器序言的字节段删掉,比如函数开头常见的55 8B EC(push ebp; mov ebp, esp),这段在几乎所有未加壳程序里都有,留着必然误报。
4.3 通配符该打在哪:跳转、立即数、重定位
通配符不是越多越好。放得太宽,签名变成「前两字节对得上就算命中」,误报会淹掉真正有价值的告警;放得太窄,壳一更新就得重写。我的经验是分三段处理:
开头 8 字节保持精确。壳的入口代码前几条指令通常很稳定,这是区分度的主要来源,不要动。
中段按指令边界通配。只看指令本身不看含义地乱打??是新手常见错误,正确做法是识别出这条指令占几个字节,把操作数部分整体通配掉,指令字节保留。比如B8 ?? ?? ?? ??是mov eax, imm32,B8必须留着。
尾部留一个精确锚点。签名末尾两三个字节精确,可以大幅降低「前面碰巧一样」的概率。这个锚点最好选在一条短指令上,比如C3(ret)或者FF 25(jmp [mem]),壳的入口末尾经常是跳转到解压代码的指令,位置稳定。
还有一个容易忽略的点:ep_only设成false的签名,通配符影响会被放大。全文件扫描时,一段 8 字节的模糊特征在几 MB 的文件里出现几十次很正常,命中数一多,工具只报第一条,你就被带到沟里去了。全文件签名我一般要求至少 16 字节且通配不超过 4 处。
4.4 自建签名的回测与优先级
写完签名必须回测,而且要用正反两组样本:一组是确认属于这个壳的,一组是确认不属于的普通程序。前者验证能命中,后者验证不误报。只测正例是自欺欺人——绝大多数签名问题都是误报,不是漏报。
回测流程我固定这样做:把改好的userdb.txt放回工具目录,重启工具(有些版本不重启不重新加载库),先跑反向样本组,全部显示未命中才算过关;再跑正向样本组,命中率低于八成说明通配打多了。两组都过,再放进正式库。
优先级问题随之而来:签名库是顺序匹配的,先命中的先返回。这意味着短签名放在长签名前面,会把长签名的结果盖掉。所以自建签名统一追加到文件末尾还不够,得把「特征更具体、名字更精确」的条目往前挪。我一般的排法是:具体版本号放前面,家族名放中间,通用短签名放最后。改完顺序同样要回测一遍,因为顺序变了,命中的名字就变了。
5. 避坑:误报、漏报和 64 位样本的五个真实翻车现场
这一章的五条,全部来自实际排查中反复出现的问题。共同点是:工具不报错,结果看起来正常,但结论是错的。查壳这种事,错一次可能让后面几小时的调试全白做,所以宁可多花两分钟验证。
5.1 64 位样本一律显示未识别
现象:一个明确加了壳的 64 位程序,拖进去显示 Nothing found,节名、EP 位置全是空的,看起来像工具坏了。
原因:经典查壳工具的解析逻辑是按 32 位 PE 写的,遇到 PE32+ 的可选头,字段偏移全变了,AddressOfEntryPoint读出来的值不对,后续所有判定连锁失效。加强版多数也只是扩了签名库,没有重写解析器。
解决:先用pefile的FILE_HEADER.Machine分流,0x8664就是 64 位,直接跳到别的工具或自己写脚本处理,别在 32 位工具上浪费时间。批量脚本里这一步必须放在最前面,否则报告里会出现大量假的「未加壳」结论,比不查还危险。
import pefile pe = pefile.PE("sample.exe", fast_load=True) if pe.FILE_HEADER.Machine == 0x8664: print("PE32+, 换用 64 位解析路径")5.2 短签名抢在前面,报出一个完全不相干的壳名
现象:一个样本显示命中了某个老牌压缩壳,但节名、EP 位置、文件体积全都对不上,按这个结论去脱壳必然失败。
原因:签名库顺序匹配,某条只有 6 个字节的短签名排在前面,而这段字节组合在目标文件里恰好也出现在 EP 处。短签名的区分度本来就低,加上通配符打得多,误命中概率不低。
解决:定位到具体是哪条签名,把它的长度补足或者直接删掉;如果必须保留,就把它挪到库的末尾,让更具体的条目先匹配。排查时可以在签名名后面临时加个标记,命中后一眼能看出是哪条规则抓的。
5.3 通配符打太宽,整批样本全被标成同一个壳
现象:批量扫描两百个文件,一百八十个显示同一个壳名,其中明显有正常程序。
原因:签名中段连续打了六七个??,实际有效的精确字节只剩开头四个。在几 MB 的文件里,这四个字节出现的概率接近必然,ep_only = false时更是如此。
解决:把连续通配压缩到 4 个以内,并在尾部补精确锚点;全文件签名强制要求 16 字节以上有效长度。改完拿正常程序组回测,误报清零才算过。
5.4 改了签名库却完全不生效
现象:userdb.txt里明明加了新条目,重启工具后跑样本,还是显示未识别,文件保存时间确认是新的。
原因:编码或换行问题。签名库对文本格式敏感,用某些编辑器保存成带 BOM 的 UTF-8,或者把换行改成 CRLF,解析器读到第一行就带了个不可见字符,整个库静默失效——不报错,不提示,只是所有签名都匹配不上。
解决:统一存成无 BOM 的 UTF-8 或 ANSI,换行用 LF。改完先看库里原有签名还能不能命中,能命中说明库被正确加载了,再去测新增条目。这个坑我踩过一次,排查了四十分钟才想到看十六进制。
5.5 EP 在节外,工具读不到字节却仍给出结论
现象:EP Section 一栏空白,签名显示未命中,但工具仍然给出了「未加壳」的判断,容易误导。
原因:AddressOfEntryPoint指向的 RVA 不落在任何已声明节内,换算失败,取不到 EP 字节,签名匹配自然全部落空。而默认结论逻辑是「没命中签名 = 未加壳」,这就形成了一个假阴性。
解决:把「EP 是否落在节内」当成一个独立判定项,而不是匹配失败后的默认分支。脚本里先判断ep_section是否为None,是就直接标「可疑:EP 异常」,不再往下走签名逻辑。人工复核时,这类文件优先级排在命中的前面。
6. 进阶:把查壳做成流水线里的一个自动关卡
单文件靠眼睛看没问题,几十上百个就必须让机器先过一遍。我的做法是三票制:签名命中、节区特征、熵值,三项里任意两项异常就进人工队列,只有一项异常的直接放行。这样既能压住误报,又不会把可疑样本漏掉。阈值我调过几轮,下面这组在几千个样本上比较稳:节熵大于 7.2、整文件熵大于 7.0 且节熵正常(说明是附加数据而非加壳)、EP 落在最后一个节且偏移小于 0x20。
6.1 三票制初筛脚本
import pefile, math from collections import Counter def entropy(b): if not b: return 0.0 c, n = Counter(b), len(b) return -sum((v / n) * math.log2(v / n) for v in c.values()) def vote(path): pe = pefile.PE(path, fast_load=True) ep = pe.OPTIONAL_HEADER.AddressOfEntryPoint last = pe.sections[-1] votes, notes = 0, [] # 票一:EP 落在最后一个节,且贴近节首 if last.VirtualAddress <= ep < last.VirtualAddress + 0x20: votes += 1 notes.append("EP 位于末节起始处") # 票二:末节熵高 e = entropy(pe.get_data(last.VirtualAddress, min(last.Misc_VirtualSize, 0x2000))) if e > 7.2: votes += 1 notes.append(f"末节熵 {e:.2f}") # 票三:整文件熵高但末节之外正常,视作附加数据,不计票 fe = entropy(pe.__data__) if fe > 7.0 and e <= 7.2: notes.append(f"整文件熵 {fe:.2f},疑似附加数据") return votes, notes逻辑说明:三票独立计算,最后按票数分流。参数0x2000是取样上限,末节动辄几 MB,全算会拖慢批量速度,8KB 已经足够反映分布。返回的notes不只是调试信息,它会直接进报告,人工复核时看一眼就知道该机器为什么报警。
6.2 人工复核的判定表
自动流程只负责排序,结论还是人下。我固定用下面这张表对可疑样本做二次确认,四项里对得上三项才写「确认加壳」:
| 检查项 | 确认加壳 | 疑似未加壳 |
|---|---|---|
| 节名 | 非标准名,或标准名但属性异常 | 标准的.text/.rdata/.data组合 |
| 导入表函数数量 | 个位数,或只有LoadLibrary/GetProcAddress | 几十个以上,覆盖常见运行时函数 |
| 节的可写可执行属性 | 存在同时可写可执行的节 | 只有代码节可执行 |
| 时间戳 | 全零、未来时间、与节区特征矛盾 | 与文件内容年代相符 |
导入表这一项特别有效,也最容易被忽略。壳为了运行时动态解析 API,会把原始导入表压到最小,导入函数数量骤降是最直接的信号,比签名可靠得多,因为它不依赖特定版本特征。
说个我自己的教训。早些年做样本初筛,图省事只信签名结果,凡是没命中的一律标「未加壳」放过去,结果一批伪装成正常节名的新壳样本全部漏掉,后面回头重扫花的时间是当初的三倍。从那以后我每次跑批量,都强制走一遍「EP 位置 + 节熵 + 导入表数量」的三项交叉,签名只作为加分项不作为唯一依据。工具再顺手,也只是把证据摆到面前,判断这一步没法外包。希望帮到你。
本文还有配套的精品资源,点击获取