diff --git a/app/engines/binary.py b/app/engines/binary.py index d696a1b..4b75efc 100644 --- a/app/engines/binary.py +++ b/app/engines/binary.py @@ -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(" len(data): + return findings + num_sections = struct.unpack_from(" 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(" 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: """判断文件名是否为双扩展名伪装。