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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
"""判断文件名是否为双扩展名伪装。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user