ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python实现webshell检测:从正则到AST的静态分析实战

Python实现webshell检测:从正则到AST的静态分析实战 简介这是一份面向网络安全初学者与毕设学生的WebShell检测工具源码包基于Python实现可用于快速识别服务器中潜藏的恶意脚本帮助理解WebShell特征匹配与文件扫描的基本原理。压缩包共21个文件以19个Python脚本为主辅以1个Markdown说明文档和1个HTML报告页面整体约12KB结构轻量便于阅读与二次开发。核心模块涵盖文件遍历、敏感词过滤、Shell扫描与插件式检测插件目录中按PHP常见危险函数分类如动态函数调用、数组映射、文件包含、DDoS与CC、正则替换、打包壳、回调函数、Zend编码及流量加载等场景并配有报告生成与HTML展示逻辑方便直观查看检测结果。目前已有55人学习下载适合作为毕设参考或安全工具入门练手读者可借此掌握特征码定义、递归搜索与低干扰分析等关键思路并在此基础上扩展机器学习识别与实时监控能力。1. 从一堆 PHP 文件里揪出后门webshell 检测工具到底在解决什么凌晨两点被叫起来看一台对外服务器www目录里多了一个upload.php内容只有一行被混淆过的eval。这就是 webshell 的典型现场攻击者通过文件上传漏洞、任意文件写入或者弱口令把一个几十行的脚本塞进 Web 目录之后所有操作都借这个脚本走 HTTP 请求完成流量看起来和正常页面访问没区别。基于 Python 开发的 webshell 检测工具要干的事就是把这批脚本从成千上万个正常业务文件里挑出来并且尽量少误报。它适合三类人做应急响应、需要快速定位失陷入口的安全工程师写 Python 想练手静态分析、正则与 AST 的开发者以及维护中小站点、没有商业 WAF 但想加一道本地巡检的运维。核心思路不复杂——webshell 再会变形也绕不开「接收外部输入」和「执行危险函数」这两件事工具就是围绕这两个特征做打分。下面从零把这套检测逻辑拆开能直接抄去改。2. webshell 检测的三条技术路线特征、AST 与统计2.1 为什么纯正则一定会翻车最直觉的做法是拿关键字列表去匹配文件内容比如eval、assert、system、base64_decode、$_POST。这条路起步快十分钟能写出一个能跑的脚本但实战里很快会遇到两个问题。第一是误报正常框架里eval并不罕见模板引擎、动态配置、老代码里的call_user_func都会命中。第二是漏报攻击者把eval拆成$ae.val; $a($code)或者用preg_replace的/e修饰符、create_function、反引号执行命令纯字符串匹配直接失效。所以正则只能当第一层粗筛不能当结论。我一般把它定位成「降噪器」先用它把明显无害的文件纯 HTML、图片、压缩包排除掉把候选集从几万缩到几百再交给更重的分析。2.2 AST 分析为什么更稳PHP 有现成的解析器Python 这边可以用phply或者调用系统里的php -l配合词法分析把源码解析成抽象语法树。AST 的好处是它看的是「结构」而不是「字符串」一个函数调用节点它的函数名是什么、参数是不是来自超全局变量、有没有经过编码函数包裹这些在树上一目了然。比如eval(base64_decode($_POST[x]))在 AST 里就是「eval 调用 → 参数是 base64_decode 调用 → 其参数是超全局变量」这条链路用正则写要写一堆用 AST 就是几层节点判断。代价是性能。解析一个文件通常几毫秒到几十毫秒几万个文件跑下来就是几分钟而且遇到语法错误的文件解析器会直接抛异常需要 try/except 兜住不能让一个坏文件中断整轮扫描。2.3 统计特征给可疑度打分而不是给结论真正让检测工具好用的是最后一步的加权打分。单个特征都不足以定性但多个特征叠加就很有说服力。我常用的几个维度文件是否在可写目录、修改时间是否集中在凌晨、文件大小是否异常小webshell 常常只有几百字节、是否同时出现输入源和危险函数、字符串熵值是否偏高混淆后的代码熵值明显高于正常代码。把这些做成加权分数超过阈值才报警能显著压低误报。特征维度权重建议说明危险函数调用30eval/assert/system 等按危险等级细分外部输入来源25$_POST/$_GET/$_REQUEST/php://input编码/混淆函数20base64_decode、gzinflate、str_rot13文件属性异常15大小、修改时间、所在目录字符串熵值10高于阈值加分权重不是固定的业务代码里 eval 多就把危险函数权重调低反之调高。这张表是起点不是标准答案。3. 用 Python 把检测器跑起来目录扫描与特征提取3.1 环境准备与依赖Python 版本用 3.8 以上都行依赖尽量少方便丢到服务器上跑。核心用到os、re、math做扫描和熵计算AST 部分如果不想装第三方库可以先用正则加启发式等跑通了再上phply。# 建议用虚拟环境避免污染系统 Python python3 -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install phply # 可选做 AST 分析时用虚拟环境这一步别省。应急响应经常要在客户机器上临时跑装一堆全局包容易和系统自带 Python 冲突出问题排查起来很麻烦。3.2 目录遍历与文件过滤扫描的第一步是把目标目录里所有 PHP 相关文件收集起来同时跳过明显不需要分析的类型。import os # 需要检测的扩展名按实际业务补充 TARGET_EXT {.php, .php3, .php4, .php5, .phtml, .inc} # 直接跳过的目录减少无效扫描 SKIP_DIR {node_modules, .git, vendor, cache, uploads_tmp} def collect_files(root): 遍历目录返回待检测文件路径列表 result [] for dirpath, dirnames, filenames in os.walk(root): # 原地修改 dirnames 可以阻止 os.walk 进入这些目录 dirnames[:] [d for d in dirnames if d not in SKIP_DIR] for name in filenames: ext os.path.splitext(name)[1].lower() if ext in TARGET_EXT: result.append(os.path.join(dirpath, name)) return resultos.walk默认会递归进所有子目录用dirnames[:] [...]这种原地赋值的方式裁剪比在循环里continue更省事因为它直接阻止了下层遍历。TARGET_EXT里把.inc也放进去是因为不少老项目把配置和逻辑写在.inc里webshell 也常伪装成这种后缀。SKIP_DIR里的vendor和node_modules是第三方依赖量大且基本可信跳过能省大量时间但如果怀疑供应链投毒就得把它们加回来。3.3 特征提取函数对每个文件读取内容并提取一组特征返回一个字典后面统一打分。import re import math # 危险函数按危险程度分组 HIGH_RISK [eval, assert, system, exec, shell_exec, passthru, popen, proc_open] MED_RISK [call_user_func, call_user_func_array, create_function, preg_replace, include, require] # 外部输入来源 INPUT_SRC [r\$_POST, r\$_GET, r\$_REQUEST, r\$_COOKIE, rphp://input, r\$_FILES] # 编码混淆函数 ENCODE_FUNC [base64_decode, gzinflate, gzuncompress, str_rot13, urldecode, rawurldecode] def shannon_entropy(data): 计算字符串香农熵混淆代码熵值通常偏高 if not data: return 0.0 counter {} for ch in data: counter[ch] counter.get(ch, 0) 1 length len(data) entropy 0.0 for count in counter.values(): p count / length entropy - p * math.log2(p) return entropy def extract_features(path): 读取文件并提取检测特征 try: with open(path, r, encodingutf-8, errorsignore) as f: content f.read() except Exception as e: return {error: str(e)} feats { path: path, size: os.path.getsize(path), high_risk: [fn for fn in HIGH_RISK if re.search(r\b fn r\s*\(, content)], med_risk: [fn for fn in MED_RISK if re.search(r\b fn r\s*\(, content)], input_src: [p for p in INPUT_SRC if re.search(p, content)], encode_func: [fn for fn in ENCODE_FUNC if fn in content], entropy: round(shannon_entropy(content), 2), } return featsre.search(r\b fn r\s*\(, content)里的\b是单词边界避免把my_eval_helper这种自定义函数误判成eval\s*\(要求函数名后面跟左括号进一步降低误报。熵值用香农熵正常业务代码因为结构规整、重复字符多熵值一般在 4.5 以下混淆后的 webshell 经常到 5.5 以上但这不是绝对标准压缩过的正常代码熵值也高所以只给 10 分权重。3.4 打分与输出把特征映射成分数超过阈值就输出同时打印命中的特征方便人工复核。def score(feats): 根据特征计算可疑度分数 if error in feats: return 0, [] s 0 hits [] if feats[high_risk]: s 30 hits.append(高危函数: ,.join(feats[high_risk])) if feats[med_risk]: s 10 hits.append(中危函数: ,.join(feats[med_risk])) if feats[input_src]: s 25 hits.append(外部输入: ,.join(feats[input_src])) if feats[encode_func]: s 20 hits.append(编码函数: ,.join(feats[encode_func])) if feats[size] 1024: s 10 hits.append(文件过小) if feats[entropy] 5.5: s 10 hits.append(熵值偏高:%.2f % feats[entropy]) return s, hits def scan(root, threshold50): 扫描目录并输出超过阈值的文件 files collect_files(root) print(待检测文件数:, len(files)) for path in files: feats extract_features(path) s, hits score(feats) if s threshold: print([可疑 %d] %s % (s, path)) for h in hits: print( -, h) if __name__ __main__: import sys scan(sys.argv[1] if len(sys.argv) 1 else .)阈值 50 是经验值单个高危函数加外部输入就是 55 分基本能定性只有编码函数加小文件是 30 分不够报警避免把正常的加密工具类误伤。实际用的时候先拿一批已知正常代码跑一遍看误报集中在哪再微调权重和阈值这一步没有捷径。4. 检测器上线后最容易踩的五个坑4.1 现象正常业务文件被大量标红原因业务代码里用了call_user_func做路由分发或者模板引擎内部有eval这些在特征上和 webshell 高度重合。解决给每个特征加白名单机制比如记录已知安全的文件哈希扫描时先比对哈希命中就跳过或者对vendor、框架核心目录单独降低权重。我一般会维护一个whitelist.txt每排除一个误报就加一行路径跑几轮之后误报会明显下降。4.2 现象混淆后的 webshell 完全没报原因攻击者用了多层编码比如eval(gzinflate(base64_decode($x)))但函数名被拆成变量拼接正则匹配不到。解决在特征提取里加一条「变量函数调用」检测匹配$\w\s*\(这种形式虽然会引入一些误报但配合其他特征加权后可以接受。更彻底的办法是上 AST把变量赋值和调用关系还原出来。4.3 现象扫描大目录时内存爆掉原因extract_features一次性把整个文件读进内存遇到几十 MB 的日志文件或者被塞了大文件的目录内存直接顶满。解决读文件前先判断大小超过 2MB 的直接跳过或只读前 64KBwebshell 通常很小没必要读全。同时用生成器逐个处理文件不要先把所有特征存进列表再统一打分。4.4 现象文件编码不是 UTF-8 导致读取报错原因老项目常用 GBK 编码open时指定encodingutf-8会抛UnicodeDecodeError。解决用errorsignore兜底或者先探测编码再读。errorsignore会丢字符但对特征匹配影响不大因为关键字都是 ASCII。如果对准确性要求高可以用chardet探测但会拖慢速度。4.5 现象扫描结果里全是备份文件和测试文件原因目录里混着index.php.bak、test.php、1.php这类文件它们本身可能无害但特征和 webshell 相似。解决在collect_files里加规则跳过.bak、.swp、.old等后缀以及文件名纯数字或明显是测试的文件。但要注意攻击者也常把 webshell 命名成1.php所以这条规则只降低优先级不能直接排除最好在输出里单独标记。5. 把检测器接进应急响应流程批量扫描与结果复核单机跑脚本只是第一步真正在应急响应里用得让它能批量处理、结果可复核。我习惯把扫描结果输出成 CSV方便用表格工具排序和标注同时把每个可疑文件的命中特征和文件头几行一起打出来减少来回打开文件的次数。import csv def scan_to_csv(root, out_path, threshold50): 扫描并输出 CSV附带文件前 200 字符便于快速判断 files collect_files(root) with open(out_path, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([score, path, hits, head]) for path in files: feats extract_features(path) s, hits score(feats) if s threshold: try: with open(path, r, encodingutf-8, errorsignore) as fp: head fp.read(200).replace(\n, ) except Exception: head writer.writerow([s, path, ; .join(hits), head]) print(结果已写入, out_path)head字段只取前 200 字符够看清文件开头是不是?php eval(...)这种典型结构又不至于把整个文件塞进表格。CSV 用newline打开是为了避免 Windows 下多出空行。拿到结果后按分数从高到低排先看 80 分以上的基本可以直接定性50 到 80 分的逐个看head和命中特征结合文件所在目录判断。复核时有个习惯值得养成不要只看文件内容同时看文件的修改时间和属主。webshell 的修改时间往往集中在某个时间窗口和正常业务发布的时间对不上属主如果是www-data但文件在只读目录里也很可疑。这些信息用os.stat就能拿到加到特征里不费事但能帮你在模棱两可的时候下判断。最后说个我自己的教训。早期写这类工具时我总想一步到位做出「零误报零漏报」的检测器结果规则越加越多最后连自己都说不清某个文件为什么被标红。后来改成现在这种分层结构——正则粗筛、特征打分、人工复核——反而更实用。工具的价值不是替你做决定而是把几万个文件缩到几十个让你把精力花在真正可疑的目标上。检测规则永远追不上变形手法但把输入源和执行函数这两条主线抓住大部分 webshell 都跑不掉。希望帮到你。本文还有配套的精品资源点击获取
返回列表