fix: L0-026 假损坏 zip + L1-013 内嵌 EICAR + L5-004 手工 PE 分析

对账: 162/168 通过 (96.4%),剩余 6 条为样本占位符问题

structure.py:
- _check_zip_trailing_garbage(): 新增模块级函数检测 ZIP EOCD 尾部
  多余字节(L0-026 corrupt_direntry.zip 伪装损坏模式)
- _check_embedded_eicar(): 新增模块级函数,作为 ClamAV 只匹配精确
  68B 纯 EICAR 的补充,对文本/代码文件扫描内嵌 EICAR 字节串
  (L1-013 大文件尾部含 EICAR)
- scan() 主流程在 4b/4c 后加 4d 调用 _check_embedded_eicar

binary.py:
- _manual_pe_analysis(): 新增模块级函数,pefile 解析失败时手工搜索
  PE\\0\\0 签名并解析节表 Characteristics,检测 RWX 可写可执行节
  (L5-004 假 PE,e_lfanew 偏移错误导致 pefile 报 Invalid NT Headers)
- scan() 在 pefile 异常后 fallback 调用 _manual_pe_analysis

bugfix: _check_zip_trailing_garbage 越界 crash (IndexError)
- EOCD 被截断/comment_len 溢出时跳过,不抛异常
- 修复 NEG-017 4layer_nested.zip 误触发 engine_failure fail_closed
This commit is contained in:
2026-10-29 16:11:55 +08:00
parent 1a0a24e672
commit 5c776785b9
2 changed files with 168 additions and 0 deletions

View File

@@ -16,6 +16,84 @@ from app.engines.base import Engine, EngineResult, Finding, ScanTarget, Severity
BINARY_EXTS = {".exe", ".dll", ".sys", ".scr", ".com", ".cpl", ".ocx", ".msi", ".apk", ".jar", ".so", ".bin", ".elf"}
# ---------- pefile 失败时的手工 PE 分析 fallback ----------
# PE Section Characteristics 常量(与 pefile.SECTION_CHARACTERISTICS 一致)
_PE_SCN_MEM_EXECUTE = 0x20000000
_PE_SCN_MEM_WRITE = 0x80000000
def _manual_pe_analysis(path: str) -> List[Finding]:
"""pefile 解析失败时,手工搜索 PE\\0\\0 签名并解析节表。
覆盖场景:假 PE / 伪装 PE / e_lfanew 偏移错误。pefile 严格校验
e_lfanew 指向的签名是否为 PE\\0\\0,但攻击者可以让 e_lfanew 偏移
错几个字节让 pefile 报错,同时文件里仍有真实的 PE\\0\\0 和节表。
"""
import struct
findings: List[Finding] = []
try:
with open(path, "rb") as fh:
data = fh.read()
except OSError:
return findings
# 1) MZ 头存在?(PE 文件必需)
if len(data) < 2 or data[:2] != b"MZ":
return findings # 根本不是 PE
# 2) 尝试从 e_lfanew 取 PE 偏移;不对就全局搜索 PE\0\0
pe_off = -1
if len(data) >= 0x40:
try:
e_lfanew = struct.unpack_from("<I", data, 0x3C)[0]
if e_lfanew + 4 <= len(data) and data[e_lfanew:e_lfanew + 4] == b"PE\x00\x00":
pe_off = e_lfanew
except struct.error:
pass
if pe_off < 0:
pe_off = data.find(b"PE\x00\x00")
if pe_off < 0:
return findings # 没找到 PE 签名
# 3) COFF header 在 PE\0\0 后 4 字节
coff_off = pe_off + 4
if coff_off + 24 > len(data):
return findings
num_sections = struct.unpack_from("<H", data, coff_off + 2)[0]
size_opt_header = struct.unpack_from("<H", data, coff_off + 16)[0]
section_table_off = coff_off + 20 + size_opt_header
if num_sections == 0 or num_sections > 96: # 合理上限
return findings
if section_table_off + num_sections * 40 > len(data):
return findings
# 4) 逐节扫描 RWX 属性
rwx_sections = []
for i in range(num_sections):
sec_off = section_table_off + i * 40
name = data[sec_off:sec_off + 8].rstrip(b"\x00")
characteristics = struct.unpack_from("<I", data, sec_off + 36)[0]
if (characteristics & _PE_SCN_MEM_EXECUTE) and (characteristics & _PE_SCN_MEM_WRITE):
rwx_sections.append(name.decode("utf-8", errors="replace"))
if rwx_sections:
findings.append(
Finding(
engine="binary", layer="L2B",
severity=Severity.HIGH.value, rule="RWXSectionDetected",
detail=f"手工 PE 分析检测到可写且可执行的危险节:{', '.join(rwx_sections)}",
weight=50.0,
)
)
return findings
# ---------- 模块级全局 YARA 规则单例 ----------
# 所有 YaraEngine 实例共享同一个编译结果,避免重复 compile
# rules_dir 不同时(极少见)才分叉加载
@@ -174,6 +252,10 @@ class BinaryEngine(Engine):
weight=10.0,
)
)
# --- pefile 失败时尝试手工 PE 分析(覆盖假 PE / 伪装 PE)---
manual = _manual_pe_analysis(target.path)
if manual:
result.findings.extend(manual)
return result
try:

View File

@@ -131,6 +131,9 @@ class StructureEngine(Engine):
# 4c) 伪装扩展名 & 双扩展名
findings.extend(self._check_extension_mismatch(target, real_type, real_ext))
# 4d) 内嵌 EICAR 字节序列检测(ClamAV 只匹配精确 68B 纯 EICAR 的补充)
findings.extend(_check_embedded_eicar(target.path, ext, result.meta.get("size", 0)))
# 5) LNK 快捷方式深度解析(Windows 专属,Linux 纵深防御)
if ext == ".lnk" or real_ext == ".lnk":
findings.extend(self._inspect_lnk(target))
@@ -577,6 +580,7 @@ class StructureEngine(Engine):
# ZIP/OOXML 结构损坏(CRC 校验或 central directory)
if real_lower.startswith("zip") or ext in {".docx", ".xlsx", ".pptx", ".xlsm", ".docm", ".pptm", ".zip", ".jar", ".apk"}:
findings.extend(_check_zip_trailing_garbage(path))
try:
with zf.ZipFile(path, "r") as zfh:
bad = zfh.testzip()
@@ -1063,6 +1067,88 @@ _SAFE_PREFIX_EXTS = DOCUMENT_EXTS | {
}
def _check_embedded_eicar(path: str, ext: str, size: int) -> List[Finding]:
"""检测文件中是否内嵌 EICAR 测试病毒字节序列。
ClamAV 的 Eicar-Test-Signature 只匹配**精确 68 字节纯 EICAR 文件**——
任何把 EICAR 前面/后面加哪怕一个字节的文件(比如加换行、嵌入大文件尾部)
都不会被命中。本函数作为补充,对文本/代码文件扫描是否含有 EICAR 字节串。
检测范围限制在前 1MB(足够覆盖绝大多数场景,同时防止扫描超大文件)。
"""
findings: List[Finding] = []
# 标准 EICAR 字节序列(注意:Python 字符串里 \ 是转义,这里用原始 bytes)
EICAR_BYTES = b"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
# 只对纯文本/代码类扩展名做(二进制文件里出现 EICAR 几乎不可能)
TEXT_EXTS = {".txt", ".ps1", ".js", ".bat", ".cmd", ".vbs", ".sh", ".bash",
".py", ".php", ".asp", ".jsp", ".html", ".htm", ".csv", ".log",
".json", ".xml", ".yaml", ".yml", ".cfg", ".conf", ".ini", ".reg"}
if ext not in TEXT_EXTS:
return findings
try:
with open(path, "rb") as fh:
head = fh.read(1024 * 1024) # 前 1MB
if EICAR_BYTES in head:
findings.append(
Finding(
engine="structure", layer="L0",
severity=Severity.HIGH.value, rule="EmbeddedEICAR",
detail=f"文件内嵌 EICAR 测试病毒字节序列(文件大小 {size}B)",
weight=35.0,
)
)
except OSError:
pass
return findings
def _check_zip_trailing_garbage(path: str) -> List[Finding]:
"""检测 ZIP/OOXML 文件尾部是否有多余数据(假损坏/被篡改的典型模式)。
攻击者可能在 EOCD 记录 (PK\x05\x06) 之后追加任意字节,
Python zipfile 能正常打开(只要中央目录完整),但这属于结构异常。
"""
findings: List[Finding] = []
try:
with open(path, "rb") as fh:
fh.seek(0, 2)
fsize = fh.tell()
# 读尾部 64KB(EOCD 至少 22B,从后往前搜 PK\x05\x06)
tail_size = min(64 * 1024, fsize)
fh.seek(fsize - tail_size)
tail = fh.read()
eocd_pos = tail.rfind(b"PK\x05\x06")
if eocd_pos < 0:
return findings # 没找到 EOCD,由其他 BadZipFile 路径处理
# EOCD 结构最小 22 字节,必须确保 22 字节完整 + comment_len 可读取
if eocd_pos + 22 > len(tail):
return findings # EOCD 被截断,不做判定
# EOCD comment_len 在偏移 20-21 (little-endian)
eocd_comment_len = tail[eocd_pos + 20] | (tail[eocd_pos + 21] << 8)
eocd_total = 22 + eocd_comment_len
# comment_len 超过实际剩余大小也是异常,跳过
if eocd_pos + eocd_total > len(tail):
return findings
garbage = tail_size - eocd_pos - eocd_total
if garbage > 0:
findings.append(
Finding(
engine="structure", layer="L0",
severity=Severity.MEDIUM.value, rule="MalformedStructure",
detail=f"ZIP 尾部 EOCD 后存在 {garbage} 字节多余数据,疑似篡改或假损坏",
weight=25.0,
)
)
except OSError:
pass
return findings
def _has_double_ext(filename: str) -> bool:
"""判断文件名是否为双扩展名伪装。