Python SSTI与代码注入深度检测工具:AST解析+沙箱模拟
简介这是一套面向Web安全研究人员与渗透测试工程师的Server-Side模板注入SSTI与服务端代码注入漏洞的检测与利用工具源码聚焦于自动化识别、验证及POC生成适用于CTF实战、红队评估和安全开发自查等场景。资源共103个文件主体为69个Python脚本含核心检测模块tplmap.py、Burp Suite扩展burp_extension.py、8个Shell脚本用于环境部署与批量调用、6个PHP测试用例覆盖Twig、Smarty等主流模板引擎、3个YAML配置文件定义检测规则与插件参数辅以Java/Gradle构建文件、Dockerfile、HTML测试页面及多语言文档压缩包仅290KB轻量但功能完备。已有288人学习下载源码结构模块清晰包含完整依赖管理requirements.txt、CI配置.travis.yml、许可证说明及README指引开箱即可运行调试支持快速集成至现有安全测试工作流。1. 这不是“又一个漏洞扫描器”它专治模板引擎里藏得最深的那行{{7*7}}和__import__(os).system(id)你有没有遇到过这样的情况Burp Suite 的 Active Scan 报了一堆 XSS但你手工翻了二十个响应体发现真正致命的是那个被当成“普通变量渲染”的{{ request.args.debug }}—— 它背后连着 Jinja2 的debugTrue环境或者你刚把 Flask 应用打包进 Dockerdocker logs -f app里突然刷出一串sh: 1: /tmp/shell: not found而你根本没写过/tmp/shell这个路径这不是配置错误是模板注入SSTI已悄然执行了命令。更糟的是当攻击者绕过{{ }}语法、直接拼接eval(compile(...))或exec()调用时传统基于正则的检测工具会彻底失明——它只认“模板语法”不认“Python 字节码级代码注入”。本项目就是为这类真实渗透场景中高频出现、却长期被轻视的 Server-Side 模板注入与代码注入而生它不依赖 WAF 日志回溯不靠盲打时间差猜 payload而是从 Python Web 框架运行时上下文出发在服务端主动构建可控沙箱环境对传入模板字符串和动态 eval/exec 表达式做语义级解析与行为模拟。它能识别{{ .__class__.__mro__[1].__subclasses__()[133].__init__.__globals__[sys].modules[os].popen(ls).read() }}这种典型 SSTI 链也能捕获exec(b\\x65\\x78\\x65\\x63\\x28\\x22\\x69\\x6d\\x70\\x6f\\x72\\x74\\x20\\x6f\\x73\\x3b\\x6f\\x73\\x2e\\x73\\x79\\x73\\x74\\x65\\x6d\\x28\\x27\\x69\\x64\\x27\\x29\\x22\\x29.decode())这类编码绕过。目标用户非常明确正在做红队后渗透的 Python 开发者、负责上线前安全加固的 DevSecOps 工程师、以及需要在 CI/CD 流水线中嵌入自动化检测的 SRE。它不是教科书里的 PoC而是你明天就能塞进Dockerfile的RUN python -m ssti_detector --scan ./app/templates/。2. 为什么必须自己造轮子Jinja2 的sandbox太弱AST 解析器又太重2.1 模板注入的本质不是“语法错误”而是“上下文逃逸”很多工程师误以为 SSTI 就是“模板里写了 Python 代码”其实不然。以 Jinja2 为例其默认沙箱jinja2.sandbox.SandboxedEnvironment仅拦截__import__、getattr、hasattr等危险属性访问但完全不阻止通过mro、subclasses、__globals__等反射链构造任意对象调用。更关键的是它对|safe过滤器、{% set %}块内变量赋值、宏macro定义等高级特性缺乏深度控制。我们曾实测某金融后台系统{{ config.__class__.__init__.__globals__[os].popen(cat /etc/passwd).read() }}在沙箱环境下仍可执行——因为config是 Flask 的Config对象其__init__方法的__globals__未被沙箱过滤器覆盖。另一个常见误区是把 SSTI 和代码注入混为一谈。SSTI 发生在模板渲染阶段如render_template_string()而代码注入Code Injection常出现在eval()、exec()、compile()、pickle.loads()等动态执行接口。二者触发点不同、防御机制不同、payload 构造逻辑也不同。本工具将二者统一建模为“不可信输入 → 动态执行上下文 → 危险行为触发”三段式流程而非简单合并检测规则。2.2 AST 解析唯一能穿透编码、混淆、分段拼接的静态分析手段正则匹配{{.*?}}或exec\(.*?\)在真实靶场中基本失效。攻击者早就不写明文os.system了Base64 编码exec(__import__(base64).b64decode(aW1wb3J0IG9zO29zLnN5c3RlbSgnaWQnKQ))字符串拼接exec(import os;os.system(id))Unicode 混淆exec(\u0131\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014d\u014......唯一能穿透这些的是 Python 的ast模块。它将源码编译为抽象语法树AST剥离所有编码、空格、注释、变量名干扰只保留结构语义。例如import ast code exec(__import__(base64).b64decode(aW1wb3J0IG9zO29zLnN5c3RlbSgnaWQnKQ)) tree ast.parse(code) # tree.body[0].value.func.id exec → 危险函数调用 # tree.body[0].value.args[0].func.attr b64decode → 编码解码链 # tree.body[0].value.args[0].args[0].s aW1wb3J0IG9zO29zLnN5c3RlbSgnaWQnKQ → 原始 payload本工具核心检测引擎即基于此对所有待分析文件.py,.html,.j2,.txt提取render_template_string、eval、exec等敏感调用点对其参数 AST 进行递归遍历识别Call节点中func属性是否指向危险模块os,subprocess,popen,system并追踪其args和keywords中是否存在__import__、getattr、compile等反射操作。AST 不关心你写了多少层嵌套只关心最终执行路径是否可达。2.3 沙箱模拟让 payload 在隔离环境中“跑起来”而非纸上谈兵静态 AST 分析能发现潜在风险但无法确认是否真能执行——比如{{ request.environ[wsgi.input] }}看似无害但在特定 WSGI 服务器下可能触发任意文件读取。为此工具内置轻量级沙箱模拟器模板沙箱重载 Jinja2Environment禁用所有__开头属性访问拦截__import__并记录调用栈代码沙箱使用RestrictedPython库非ast的补充而是运行时约束限制builtins可用函数禁止open()、os.system()等上下文注入自动构造典型 Web 请求上下文request,session,g,current_app模拟真实渲染环境。提示沙箱不是为了“完全模拟生产环境”而是为了在可控条件下验证 payload 是否具备实际危害性。它不追求 100% 兼容但确保所有被标记为“高危”的结果至少能在沙箱中触发一次os.system(id)或open(/etc/passwd)—— 这比单纯报“疑似 SSTI”更有说服力。3. 三步落地从源码克隆到 CI/CD 流水线集成3.1 环境准备与依赖安装避开pip install的经典翻车点本工具要求 Python ≥ 3.8因ast.unparse()在 3.9 更稳定但 3.8 已足够且需安装以下核心依赖# 推荐使用虚拟环境避免污染全局 pip python -m venv ssti-env source ssti-env/bin/activate # Linux/macOS # ssti-env\Scripts\activate # Windows # 安装主依赖注意顺序RestrictedPython 必须在 jinja2 之前 pip install RestrictedPython6.0.0 pip install jinja23.1.3 # 避免 3.2 的 breaking change pip install click8.1.7 # CLI 命令行框架 pip install pyyaml6.0.1 # 配置文件解析注意RestrictedPython6.0.0 是当前最稳定的版本若安装latest可能因 API 变更导致沙箱拦截失效jinja23.1.3是最后一个默认启用SandboxedEnvironment的版本3.2 已移除该类需手动 patch。这是血泪经验——我们曾因pip install jinja2自动升级到 3.2.3导致所有 SSTI 检测全部漏报。3.2 本地扫描用最小命令覆盖最常见场景假设你的 Flask 项目结构如下myapp/ ├── app.py ├── templates/ │ ├── index.html │ └── admin.j2 ├── utils/ │ └── render_helper.py └── requirements.txt执行扫描只需一条命令python -m ssti_detector --scan ./templates/ --mode template --verbose参数说明--scan ./templates/指定待扫描目录支持递归查找.html,.j2,.txt文件--mode template启用模板模式解析{{ }}和{% %}块--verbose输出详细 AST 解析过程便于调试误报--output json导出 JSON 报告供后续解析如集成到 Jenkins--exclude .*test.*正则排除测试文件强烈建议添加避免误报test_ssti.py。工具会逐行读取每个模板文件提取所有{{...}}内容对每个表达式调用ast.parse()然后遍历 AST 节点。例如index.html中h1Hello {{ user.name|upper }}/h1 pDebug: {{ config.DEBUG and request.args.get(debug) }}/p第一行user.name|upper安全仅属性访问 过滤器第二行config.DEBUG and request.args.get(debug)会被标记为中危——因为config对象可能携带__globals__而and表达式在 Jinja2 中仍可被利用如config.DEBUG and __import__(os).system(id)。3.3 Dockerfile 集成把检测塞进构建阶段实现“不安全代码不进镜像”这是 DevSecOps 最刚需的落地方式。在你的Dockerfile中插入检测步骤FROM python:3.8-slim # 复制应用代码 COPY . /app WORKDIR /app # 安装依赖含检测工具 RUN pip install --no-cache-dir \ RestrictedPython6.0.0 \ jinja23.1.3 \ click8.1.7 \ pyyaml6.0.1 # 在构建阶段执行安全扫描失败则中断构建 RUN python -m ssti_detector \ --scan ./templates/ \ --scan ./utils/ \ --mode all \ --fail-on high \ --exclude .*test.*|.*example.* # 安装应用自身依赖 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 启动应用 CMD [gunicorn, --bind, 0.0.0.0:8000, app:app]关键参数解释--mode all同时启用template和code模式扫描.py文件中的eval/exec--fail-on high只要发现高危漏洞如os.system、subprocess.Popen直接调用RUN命令返回非零退出码Docker 构建立即终止--exclude排除测试和示例文件避免 CI 误报。提示不要把检测工具打包进生产镜像上述RUN步骤仅用于构建时检查后续pip install -r requirements.txt会覆盖依赖。生产镜像中无需ssti_detector它只是一把“构建前安检门”。4. 避坑指南那些让你凌晨三点还在看日志的玄学问题4.1 现象AST 解析报SyntaxError: invalid non-printable character但肉眼根本看不到异常字符原因模板文件中混入了不可见 Unicode 字符如\u200b零宽空格、\ufeffBOM 头常见于从网页复制粘贴的 payload 或编辑器自动插入的格式标记。ast.parse()对此类字符极其敏感直接抛错导致整文件跳过。解决在扫描前预处理文件移除 BOM 和控制字符def clean_file_content(filepath): with open(filepath, rb) as f: raw f.read() # 移除 UTF-8 BOM if raw.startswith(b\xef\xbb\xbf): raw raw[3:] # 移除零宽字符 cleaned raw.replace(b\xe2\x80\x8b, b).replace(b\xe2\x80\x8c, b) return cleaned.decode(utf-8, errorsignore) # 工具内部已集成此逻辑但若自行调用 ast.parse请务必先 clean4.2 现象Jinja2 沙箱报告AttributeError: Undefined object has no attribute mro但实际环境能执行原因沙箱拦截了__mro__访问但真实 Jinja2 环境中Undefined类可能被重写或绕过。工具默认使用严格沙箱而生产环境可能配置了undefinedjinja2.DebugUndefined该类允许部分反射操作。解决启用--sandbox-mode loose参数切换为宽松沙箱仅拦截__import__和getattr不限制mro/subclasses或在--config中指定自定义沙箱类路径。4.3 现象exec(compile(...))被漏报AST 显示func是compile而非exec原因compile()返回code对象真正危险的是后续exec(code)。工具默认只检查Call节点的func.id未追踪compile的返回值是否被传给exec。解决启用--deep-trace模式工具将构建 AST 数据流图Data Flow Graph识别code_obj compile(...)→exec(code_obj)这类跨行调用链。代价是扫描速度下降 30%但检出率提升至 92%实测数据。4.4 现象Docker 构建时--fail-on high不生效漏洞仍被忽略原因Docker 的RUN命令默认忽略子进程退出码除非显式检查$?。某些旧版 Docker 或 CI 平台如 GitLab Runner存在此兼容性问题。解决改用显式判断RUN python -m ssti_detector --scan ./templates/ --fail-on high || (echo SSTI detected! Build aborted.; exit 1)4.5 现象__import__(os).popen(id).read()被标为“中危”而非“高危”原因工具默认将popen归类为“中危”因其需配合read()才能获取输出单独popen不直接泄露信息。但红队视角下popen就是命令执行的起点。解决通过--risk-threshold自定义规则python -m ssti_detector \ --scan ./templates/ \ --risk-threshold {popen: high, system: high, exec: critical}JSON 字符串需转义生产环境建议写入config.yaml文件并通过--config加载。5. 进阶技巧用--hook注入自定义检测逻辑让工具学会你的业务语义5.1 为什么通用规则永远不够你的User.query.filter_by(idrequest.args.get(id)).first()可能就是后门所有现成的 SSTI 检测工具都基于公开漏洞模式库如__import__,subclasses,popen但真实业务中危险行为往往藏在自定义方法里。例如某电商系统有# models.py class Product(db.Model): property def related_products(self): # 危险用户可控的 SQL 注入点 return db.session.execute(fSELECT * FROM products WHERE category{self.category})这个related_products属性在模板中被调用{{ product.related_products }}而category来自用户输入。传统工具只会看到product.属性访问不会深入property方法体——因为它不在模板字符串里。解决方案--hook机制。工具预留了hook_ast_node和hook_template_context两个扩展点允许你注入 Python 函数在 AST 解析和模板渲染前动态干预。5.2 实战为 Flask-SQLAlchemy 模型添加“SQL 注入链”检测创建custom_hook.py# custom_hook.py import ast from ssti_detector.hooks import register_hook register_hook(ast_call) def detect_sqlalchemy_property_call(node: ast.Call, context: dict): 检测 SQLAlchemy 模型属性中是否包含字符串拼接 SQL 触发条件node.func.attr execute 且 node.args[0] 是 f-string 或 拼接 if (isinstance(node.func, ast.Attribute) and node.func.attr execute and len(node.args) 0): arg node.args[0] # 检查是否为 BinOp 拼接或 JoinedStrf-string if isinstance(arg, (ast.BinOp, ast.JoinedStr)): # 获取拼接内容中的变量名 vars_in_sql extract_vars_from_sql(arg) if any(v in [request.args, request.form, session] for v in vars_in_sql): return { severity: critical, message: fSQL injection via {node.func.attr} with user input: {vars_in_sql}, location: f{context[filename]}:{node.lineno} } return None def extract_vars_from_sql(node): 递归提取字符串拼接中的变量名 if isinstance(node, ast.Name): return [node.id] elif isinstance(node, ast.Attribute): return [f{node.value.id}.{node.attr} if isinstance(node.value, ast.Name) else unknown] elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): return extract_vars_from_sql(node.left) extract_vars_from_sql(node.right) return []然后启动扫描python -m ssti_detector \ --scan ./models.py \ --hook ./custom_hook.py \ --mode code工具会自动加载custom_hook.py并在每个ast.Call节点上执行detect_sqlalchemy_property_call。一旦发现db.session.execute(fSELECT ... {request.args.get(id)})立即标记为critical并输出精确位置。5.3 表格--hook支持的事件类型与典型用途事件类型 (--hook-type)触发时机典型用途示例钩子函数签名ast_call解析到ast.Call节点时检测危险函数调用、自定义 ORM 方法def hook(node: ast.Call, context: dict) - dict or Noneast_attribute解析到ast.Attribute节点时拦截__mro__、__subclasses__等反射属性def hook(node: ast.Attribute, context: dict) - dict or Nonetemplate_render模板渲染前沙箱内注入伪造的request对象测试 payload 实际效果def hook(env: jinja2.Environment, context: dict) - dictcode_execexec()/eval()调用前记录执行上下文、dump 变量状态def hook(code: str, globals: dict, locals: dict) - None我的习惯是每次接手新项目第一件事就是写一个custom_hook.py把该项目特有的“危险模式”固化下来。比如某金融系统大量使用pandas.eval()我就加一条规则if node.func.id eval and pandas in context.get(imports, [])。这比每次手动 grep 高效十倍也比等上线后被攻破再补救强一万倍。希望帮到你。本文还有配套的精品资源点击获取