diff --git a/app/engines/keywords.py b/app/engines/keywords.py index f98a6c2..0c0f34d 100644 --- a/app/engines/keywords.py +++ b/app/engines/keywords.py @@ -65,6 +65,13 @@ class KeywordEngine(Engine): if not text: return result + # ---- 预处理:合并换行符以支持跨行关键词 ---- + # 攻击者会把 Invoke-WebRequest 这种关键字用 CRLF 拆成 + # "Invoke-Web\r\nRequest" 来绕过基于单行的匹配。 + # 这里把所有 \r\n / \r / \n 替换为空,让跨行关键词重新拼接。 + # 注意:不影响纯文本/多行语义,我们匹配的都是单行关键词或跨行命令。 + text = text.replace("\r\n", "").replace("\r", "").replace("\n", "") + for rule in self._compiled: match_mode = rule.get("match", "contains") rule_name = rule.get("name", "keyword") diff --git a/app/engines/structure.py b/app/engines/structure.py index 06c2ad4..4bd92e6 100644 --- a/app/engines/structure.py +++ b/app/engines/structure.py @@ -647,19 +647,10 @@ class StructureEngine(Engine): filename = (target.filename or "").lower() # 双扩展名:安全扩展名 + 危险扩展名结尾(如 invoice.pdf.exe) - safe_then_dangerous = ( - filename.endswith(".exe") or filename.endswith(".dll") or filename.endswith(".scr") - or filename.endswith(".com") or filename.endswith(".pif") or filename.endswith(".cpl") - or filename.endswith(".msi") or filename.endswith(".lnk") or filename.endswith(".chm") - or filename.endswith(".bat") or filename.endswith(".cmd") or filename.endswith(".ps1") - or filename.endswith(".vbs") or filename.endswith(".hta") - or filename.endswith(".elf") or filename.endswith(".so") - or filename.endswith(".desktop") or filename.endswith(".run") or filename.endswith(".bin") - or filename.endswith(".deb") or filename.endswith(".appimage") - ) - safe_exts = {".txt", ".pdf", ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".doc", ".docx", - ".xls", ".xlsx", ".ppt", ".pptx", ".rtf", ".csv", ".xml", ".html", ".htm", - ".zip", ".rar", ".7z", ".wps", ".et", ".dps", ".wpt", ".ett", ".dpt"} + # 用 DANGEROUS_EXTS 做单一事实源,避免手工维护 endswith 链漏覆盖 + _dangerous_tuple = tuple(sorted(DANGEROUS_EXTS, key=len, reverse=True)) + safe_then_dangerous = filename.endswith(_dangerous_tuple) + safe_exts = _SAFE_PREFIX_EXTS if safe_then_dangerous: # 取最后两个扩展名 @@ -685,7 +676,7 @@ class StructureEngine(Engine): return findings # 真实类型危险可执行,声明安全类型 → 伪装 - from app.engines.structure import DANGEROUS_EXTS + # DANGEROUS_EXTS 是模块级常量,此处直接引用 if real_ext in DANGEROUS_EXTS and ext not in DANGEROUS_EXTS and ext: findings.append( Finding( @@ -1063,17 +1054,33 @@ def _is_path_traversal(name: str) -> bool: return ".." in parts +# 安全/文档类扩展名集合——双扩展名伪装中作为"前缀"出现 +_SAFE_PREFIX_EXTS = DOCUMENT_EXTS | { + ".txt", ".log", ".csv", ".json", ".xml", ".html", ".htm", + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".svg", + ".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", + ".wps", ".et", ".dps", ".wpt", ".ett", ".dpt", ".rtf", ".odt", +} + + def _has_double_ext(filename: str) -> bool: - """判断文件名是否包含两个以上扩展名。""" + """判断文件名是否为双扩展名伪装。 + + 正确逻辑:最后一个扩展名是 DANGEROUS_EXTS 里的危险可执行类型(黑名单基准, + 单一事实源),倒数第二个是安全/文档类(可能被攻击者故意放在前面伪装)。 + 例如 invoice.docx.js → .js 危险 + .docx 安全 → True。 + """ name = os.path.basename(filename) parts = name.split(".") if len(parts) < 3: return False prefix_ext = "." + parts[-2].lower() suffix_ext = "." + parts[-1].lower() - if suffix_ext not in SUSPICIOUS_DOUBLE_EXT: + # 核心:以 DANGEROUS_EXTS 为单一事实源 + if suffix_ext not in DANGEROUS_EXTS: return False - return prefix_ext in DANGEROUS_EXTS or prefix_ext in SUSPICIOUS_DOUBLE_EXT + # 前缀必须是安全/文档类,才算"伪装" + return prefix_ext in _SAFE_PREFIX_EXTS def _compatible_exts(real_ext: str, real_type: str) -> set[str]: