"""L0 结构与完整性检测引擎。 职责: - 真实文件类型识别(不信任扩展名) - 扩展名伪装、双扩展名检测 - 危险可执行扩展名识别 - 压缩包结构安全预检(压缩比、成员数量、路径穿越) """ from __future__ import annotations import os import tarfile import zipfile from typing import Any, Dict, List from app.engines.base import Engine, EngineResult, Finding, ScanTarget, Severity # 高危可执行/脚本扩展名。这些类型即使内容"干净",在内网被打开即可能造成危害。 # 包含跨平台 + Windows 专属 + Linux 专属三类(见总体方案 §4.8.3) DANGEROUS_EXTS = { # === Windows 专属 === ".exe", ".dll", ".scr", ".com", ".pif", ".cpl", ".msi", ".msp", ".msc", ".bat", ".cmd", ".com2", ".ps1", ".psm1", ".vbs", ".vbe", ".js", ".jse", ".wsf", ".wsh", ".hta", ".reg", ".chm", ".lnk", ".gadget", ".inf", ".scpt", # === Linux 专属 === ".elf", ".so", ".desktop", ".appimage", ".flatpak", ".snap", ".deb", ".rpm", ".run", ".bin", # === 跨平台脚本 === ".jar", ".apk", ".app", ".iso", ".sh", ".bash", ".zsh", ".py", ".pyc", ".pl", ".rb", ".php", ".jsp", ".asp", ".aspx", } # 常见文档扩展名,用于识别"文档里藏可执行文件"的场景 DOCUMENT_EXTS = { ".doc", ".docx", ".docm", ".dot", ".dotm", ".rtf", ".odt", ".xls", ".xlsx", ".xlsm", ".xlsb", ".xlt", ".xltm", ".ods", ".ppt", ".pptx", ".pptm", ".pot", ".potm", ".odp", ".pdf", ".wps", ".et", ".dps", } # 归档扩展名 ARCHIVE_EXTS = { ".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", ".xz", ".tgz", ".cab", ".iso", ".jar", ".war", ".apk", ".apks", ".xpi", } # 双扩展名检查:前缀扩展名落在危险集合中即判定为伪装 SUSPICIOUS_DOUBLE_EXT = { ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".jpg", ".png", ".zip", ".rar", ".csv", } # 允许在归档中出现的无害扩展名(用于评估"归档内含可执行"的风险) BENIGN_IN_ARCHIVE = { ".txt", ".md", ".csv", ".json", ".xml", ".html", ".htm", ".pdf", ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".mp3", ".mp4", ".avi", ".wav", } class StructureEngine(Engine): """结构层检测引擎。""" name = "structure" layer = "L0" def scan(self, target: ScanTarget, ctx: Any) -> EngineResult: result = EngineResult(engine=self.name) findings: List[Finding] = [] ext = (target.declared_ext or "").lower() real_type, real_ext, mime = identify_type(target.path) result.meta.update( { "real_type": real_type, "real_ext": real_ext, "mime": mime, "size": os.path.getsize(target.path), "declared_ext": ext, } ) # 1) 扩展名与真实类型不一致 if ext and real_ext and ext not in _compatible_exts(real_ext, real_type): findings.append( Finding( engine=self.name, layer=self.layer, severity=Severity.HIGH.value, rule="ExtensionMismatch", detail=f"扩展名声明为 {ext},实际内容为 {real_type}({real_ext})", weight=30.0, ) ) # 2) 双扩展名伪装 if _has_double_ext(target.filename): findings.append( Finding( engine=self.name, layer=self.layer, severity=Severity.HIGH.value, rule="DoubleExtension", detail=f"文件名包含多个扩展名:{target.filename}", weight=35.0, ) ) # 3) 危险可执行扩展名(顶层文件) if ext in DANGEROUS_EXTS and not target.is_extracted: severity = Severity.CRITICAL.value if ext in {".exe", ".scr", ".dll", ".com", ".pif"} else Severity.HIGH.value findings.append( Finding( engine=self.name, layer=self.layer, severity=severity, rule="DangerousExecutableExtension", detail=f"文件类型为可执行/脚本格式:{ext}", weight=45.0 if severity == Severity.CRITICAL.value else 30.0, ) ) # 4) 归档结构预检 if ext in ARCHIVE_EXTS or real_ext in ARCHIVE_EXTS: findings.extend(self._inspect_archive(target, ctx)) # 4b) 畸形文件启发式(P1)——结构完整性校验,仅降权不零容忍 findings.extend(self._check_malformed(target, real_type)) # 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)) # 6) ISO 挂载递归扫描(IsoWithExecutable 零容忍规则) if ext == ".iso" or real_ext == ".iso": findings.extend(self._inspect_iso(target)) # 7) CHM 帮助文件结构校验(ChmWithScript 零容忍规则) if ext == ".chm" or real_ext == ".chm": findings.extend(self._inspect_chm(target)) # 8) Linux 专属零容忍检测 findings.extend(self._check_linux_threats(target, real_type, real_ext)) result.findings = findings return result # ------------------------------------------------------------------ LNK 解析 def _inspect_lnk(self, target: ScanTarget) -> List[Finding]: """深度解析 .lnk 快捷方式的目标路径、参数与远程引用。 Windows 专属载体,在内网麒麟/UOS 终端上不生效,但保持检测—— 文件可能通过其他路径流向运维人员的 Windows 工作站(纵深防御)。 """ import struct findings: List[Finding] = [] path = target.path try: with open(path, "rb") as fh: data = fh.read(8192) except OSError: return findings if len(data) < 76: return findings # 1) Shell Link Header 校验 header_size = struct.unpack_from(" 0: offset += 2 + idlist_size # 3) LinkInfo(有 size 前缀;HasLinkInfo flag = bit 1) has_link_info = bool(link_flags & 0x00000001) target_path = "" arguments = "" if has_link_info and offset + 4 <= len(data): linkinfo_size = struct.unpack_from("= 4 and offset + linkinfo_size <= len(data): linkinfo = data[offset: offset + linkinfo_size] strings = self._extract_utf16_strings(linkinfo) # 优先选 UNC 或盘符开头的路径;否则选第一个可疑路径 for s in strings: lowered = s.lower() # UNC 远程路径 if s.startswith("\\\\"): target_path = s break # 盘符开头的本地路径 if len(s) >= 2 and s[1] == ":" and s[0].isalpha(): target_path = s break if not target_path: # 备选:任何以危险后缀结尾的字符串都记为 target_path dangerous_suffixes = (".exe", ".cmd", ".ps1", ".vbs", ".bat", ".com", ".scr", ".hta", ".js", ".jar", ".msi", ".run", ".bin", ".sh", ".bash") for s in strings: if s.lower().endswith(dangerous_suffixes): target_path = s break offset += linkinfo_size # 4) Name / RelativePath / WorkingDir / Arguments(每个:2字节 length + UTF-16LE 内容) # LinkFlags bit 5 = HasArguments → Arguments 存在 flags_table = [ ("HasName", 0x00000004), # bit 2 ("HasRelativePath", 0x00000008), # bit 3 ("HasWorkingDir", 0x00000010), # bit 4 ("HasArguments", 0x00000020), # bit 5 ] for name, flag in flags_table: if not (link_flags & flag): continue if offset + 2 > len(data): break str_len = struct.unpack_from(" len(data): break try: text = data[offset: offset + byte_len].decode("utf-16-le", errors="replace").rstrip("\x00") offset += byte_len + 2 # null terminator except Exception: # noqa: BLE001 break if name == "HasArguments" and text: arguments = text elif name == "HasRelativePath" and text and not target_path: target_path = text # 5) 安全判定 dangerous_exts = (".exe", ".cmd", ".ps1", ".vbs", ".bat", ".com", ".scr", ".hta", ".js", ".jar", ".msi", ".lnk", ".bin", ".run", ".sh", ".bash") # 远程 UNC 引用 if target_path.startswith("\\\\"): findings.append( Finding( engine=self.name, layer=self.layer, severity=Severity.CRITICAL.value, rule="LnkWithAutoExec", detail=f"LNK 指向远程 UNC 路径:{target_path[:120]}", weight=85.0, ) ) # 指向可执行文件 if target_path.lower().endswith(dangerous_exts): findings.append( Finding( engine=self.name, layer=self.layer, severity=Severity.CRITICAL.value, rule="LnkWithAutoExec", detail=f"LNK 指向可执行文件:{target_path[:120]}", weight=85.0, ) ) # Arguments 含危险命令 if arguments: dangerous_args = ( ("cmd /c", "调用 cmd 执行命令"), ("powershell", "调用 PowerShell"), ("mshta", "调用 mshta 执行 HTML 应用"), ("rundll32", "调用 rundll32"), ("regsvr32", "调用 regsvr32"), ("wscript", "调用 WScript"), ("cscript", "调用 CScript"), ("certutil", "certutil -decode 下载"), ("bitsadmin", "BITS 下载"), ("wget", "下载"), ("curl", "下载"), ("-enc ", "PowerShell 编码命令"), ("-EncodedCommand", "PowerShell 编码命令"), ("/bin/sh", "Linux Shell 调用"), ) lowered_args = arguments.lower() for trigger, desc in dangerous_args: if trigger.lower() in lowered_args: findings.append( Finding( engine=self.name, layer=self.layer, severity=Severity.CRITICAL.value, rule="LnkWithAutoExec", detail=f"LNK Arguments 含危险命令({desc}):{arguments[:120]}", weight=85.0, ) ) break # 一条足够触发零容忍 return findings @staticmethod def _extract_utf16_strings(data: bytes) -> List[str]: """从二进制数据中提取所有可打印 UTF-16LE 字符串。""" strings: List[str] = [] try: decoded = data.decode("utf-16-le", errors="replace") except Exception: # noqa: BLE001 return strings # 按 null 分割,过滤短/非 ASCII for s in decoded.split("\x00"): s = s.strip("\x00") if len(s) >= 4 and any(c.isprintable() for c in s): strings.append(s) return strings # ------------------------------------------------------------------ ISO 扫描 def _inspect_iso(self, target: ScanTarget) -> List[Finding]: """ISO 9660 镜像目录树扫描——识别内部可疑可执行文件(触发 IsoWithExecutable)。 不依赖 mount 或外部库,直接解析 ISO 9660 的 PVD + 根目录 + 子目录。 在 Linux 生产环境中,提取器层(SafeExtractor)会用 mount -o loop 递归扫描, 此处提供结构级快速识别——ISO 包含可执行类型文件时即触发零容忍。 """ import struct findings: List[Finding] = [] path = target.path try: with open(path, "rb") as fh: data = fh.read(256 * 1024) # 读前 256KB(足够覆盖 PVD + 多个目录) except OSError: return findings # ISO 9660 PVD 位于 sector 16,每个 sector 2048 字节 # PVD 标识字节 0x01,其后 "CD001" 标识 pvd_offset = 16 * 2048 if pvd_offset + 8 > len(data): return findings if data[pvd_offset] != 0x01 or data[pvd_offset + 1: pvd_offset + 6] != b"CD001": return findings # 不是合法 ISO 9660 # PVD 偏移 156-164: 根目录 record 的 LBA + size root_lba = struct.unpack_from(" List[str]: """递归遍历 ISO 目录树,收集可执行文件路径。""" import struct if current_depth >= max_depth: return [] dir_offset = lba * 2048 if dir_offset + 4 > len(data): return [] found: List[str] = [] pos = dir_offset while pos < len(data): entry_len = data[pos] if entry_len == 0: pos += 1 # padding continue if entry_len == 0xFF: break # end of directory entries if pos + entry_len > len(data): break entry = data[pos: pos + entry_len] # Directory Record 结构: # 0 entry_len (1) # 1 ext_len (1) # 2-9 LBA (8, both endian) # 10-13 size (4, both endian) # 14-20 日期时间 (7) # 21 flags (1) # 22-23 interleave (2) # 24-25 volume_seq (2) # 26 name_len (1) # 27+ name (variable) if len(entry) < 28: pos += entry_len continue flags = entry[21] name_len = entry[26] lba_child = struct.unpack_from(" List[Finding]: """HTML Help (.chm) 文件结构校验——识别脚本/可执行内容(触发 ChmWithScript)。 CHM 本质是一个 ITSF 容器,内部的 #SYSTEM 流定义了整个帮助系统的结构。 重点检查:1) ITSF 头合法性;2) #SYSTEM 中是否有 .exe/.cmd/.vbs/.js/.hta 引用; 3) 是否包含 ActiveX Object / Shell Script Object 等可执行 HTML 组件。 """ import struct import zlib findings: List[Finding] = [] path = target.path try: with open(path, "rb") as fh: data = fh.read(256 * 1024) # 前 256KB except OSError: return findings # ITSF 头: signature "ITSF" (4) + version (4) + unknown (4) + section_offset (4) if len(data) < 16 or data[:4] != b"ITSF": return findings # 尝试在数据中搜索脚本/可执行特征 text_hits: List[str] = [] # 直接搜索整个文件(CHM 通常只有几 MB) lowered = data.lower() # 1) ITSF section header + streams 枚举 → 找 .exe/.cmd/.vbs/.js/.hta executable_refs = [ b".exe", b".cmd", b".bat", b".ps1", b".vbs", b".vbe", b".hta", b".js", b".jse", b".wsf", b".scr", ] for ref in executable_refs: pos = 0 while True: idx = lowered.find(ref, pos) if idx < 0: break # 取前后各 20 字节作为上下文 start = max(0, idx - 20) end = min(len(data), idx + len(ref) + 20) context = data[start:end].decode("ascii", errors="replace").strip("\x00") text_hits.append(context) pos = idx + 1 if len(text_hits) >= 5: break if len(text_hits) >= 5: break # 2) ActiveX / Shell Script Object 引用 ax_refs = [ b"object. Shell", b"wscript.shell", b"scriptlet", b"mshtmbrowser", b"html.application", b"objshell.application", ] for ref in ax_refs: if ref in lowered: text_hits.append(f"ActiveX/Script 引用: {ref.decode('ascii', errors='replace')}") if text_hits: findings.append( Finding( engine=self.name, layer=self.layer, severity=Severity.CRITICAL.value, rule="ChmWithScript", detail=f"CHM 帮助文件包含脚本/可执行引用:{'; '.join(text_hits[:3])}", weight=80.0, ) ) # 3) 快速扫描:HTML 内容中是否包含