ARTICLE DETAIL

资讯详情

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

SSTI绕过实战:fenjing工具原理与使用指南

SSTI绕过实战:fenjing工具原理与使用指南 在CTF和渗透测试里SSTI服务端模板注入大概是那种“一看就会一绕就废”的漏洞。很多初学者拿着{{7*7}}试出49之后兴奋地准备读到配置文件结果卡在过滤上__globals__被ban、点号被替换、括号被过滤、甚至数字字母都被限制。手工绕一轮下来头发掉一半。后来我接触了fenjing这个工具它几乎是把SSTI绕过的脏活累活全包了。这篇文章我就从自己在靶场和授权测试中的实际经验出发聊聊fenjing的工作原理、使用方式以及我在踩坑之后总结出来的一套排查思路。无论你是刚开始接触SSTI的新手还是已经在手工构造payload的老手这篇文章都能给你一些可以落地的东西。1. SSTI到底是什么为什么需要fenjing这种工具1.1 模板注入的原理与典型场景SSTI的全称是Server-Side Template Injection中文叫服务端模板注入。它的本质是服务端把用户输入直接拼接进模板引擎的解析流程里导致输入被当成模板代码执行。常见的模板引擎有Jinja2Python、TwigPHP、FreemarkerJava、VelocityJava、ERBRuby等各自语法不同但危险性是类似的。拿Python Web开发中最常见的Jinja2举例一个典型的漏洞代码可能是这样的from flask import Flask, request, render_template_string app Flask(__name__) app.route(/hello) def hello(): name request.args.get(name, world) template fHello, {name}! return render_template_string(template)这里name参数直接进了模板字符串。正常访问/hello?nameadmin会输出Hello, admin!。但如果传入{{7*7}}Jinja2会把它当作表达式求值输出Hello, 49!。一旦确认存在模板注入攻击者就可以尝试读取配置、环境变量甚至通过Python对象链寻找危险的子类最终执行系统命令。这类问题在现实业务里并不少见尤其是那些“为了方便”把用户输入直接丢给模板引擎渲染的场景。比如邮件模板自定义、页面个性化配置、低代码平台的数据绑定等。很多开发者以为用户输入只是字符串实际上模板引擎会把这些“字符串”当成代码解析这就是最核心的误解。1.2 手工绕过的痛苦才催生了自动化工具理论上模板注入的利用思路很清晰先探测引擎类型再构造对象链最后执行命令。但真实场景里目标往往不是光秃秃的{{7*7}}前面总有一道道过滤。我在一次授权测试中遇到过一个Jinja2注入点输入框对[]和.做了全局替换连{{和}}都差点被过滤。当时手工构造payload花了三个多小时期间尝试了attr过滤器、中括号写法、十六进制编码、unicode编码等各种姿势最终才绕过去。那次之后我就决定找个工具把这种重复劳动替代掉。说句实话工具不可能替代人的判断但可以把“确定过滤规则后反复尝试绕过”这个阶段加速几十倍。fenjing就是这样一个定位的工具。它做了三件我很看重的事自动识别后端模板引擎类型自动探测过滤规则自动生成能绕过当前规则的payload听起来很美好但用起来有哪些坑、底层逻辑是什么我们下面慢慢拆。2. fenjing工具选型与核心思路拆解2.1 fenjing是什么它解决的核心问题fenjing是一个针对SSTI的自动化工具名字取自“风净”的拼音。它主要面向Jinja2模板引擎同时也支持对Mako、Tornado等引擎的探测。GitHub上是一个基于Python的命令行工具可以安装在本地然后通过HTTP请求向目标注入payload。如果你理解Burp Suite里的Intruder模块那可以这样类比Intruder是给你一个位置让你手动放置payload并批量发送fenjing则是把“生成payload、测试是否可用、再发给目标”这个闭环自动化了。它通过不断向目标发送探测请求学习到当前环境的过滤特征再用优化算法生成一个能完整绕过过滤的表达式链。它解决的核心问题就是当目标对关键字、符号、数字都做了过滤时人工构造一个既能绕过过滤又能完成利用的请求成本实在太高。fenjing通过对payload的多维度编码、拆解、重组把这一过程交给算法去跑。对于CTF选手来说这相当于一个自动绕WAF的加速器对于安全测试人员来说它能把时间从“不眠不休调半天”压缩到“喝口水看结果”。2.2 工具的工作流程与策略我在使用过程中把fenjing的执行逻辑拆成了几步理解这几步对排查问题特别重要第一步是探测。启动后工具会先向目标URL发送一个带有已知探测标志的请求确认目标是否存在SSTI、后端用的是哪个模板引擎。fenjing会尝试{{7*7}}、${7*7}、% 7*7 %等多组检测样本哪个返回了运算结果就说明该类型引擎存在。第二步是发现过滤规则。确认引擎之后工具会依次发送包含各类敏感字符和关键字的请求比如.,_,[,],__class__,__globals__,os,popen等。根据响应状态码、返回内容是否包含关键字来判断当前环境对哪些字符做了处理。第三步是生成可用payload。这一步是fenjing的核心亮点。它维护了一个payload模板库把__class__.__mro__[2].__subclasses__()这类标准链先拆成“片段”再针对发现的过滤规则给每个片段寻找替代表示。比如点号用|attr()替代下划线用\x5f编码中括号用__getitem__或pop调用替代数字用算术表达式生成字符串用chr()拼接。整个过程有点像拼积木每个积木块都有多个变体算法负责找出能通过验证的组合。第四步是发送并确认。payload生成后fenjing会实际发送请求通过响应内容确认命令是否执行成功、数据是否回显。如果失败它会尝试调整编码方式或更换利用链重新测试。2.3 安装与基本用法fenjing是Python写的安装非常容易。我的环境是Python 3.10直接用pip安装pip install fenjing装好以后先看下帮助文档fenjing --help常用的参数包括--url、--method、--data、--cookie、--headers等。一个最简单的探测命令是fenjing --url http://127.0.0.1:5000/hello?namefenjing它会自动完成探测并输出可用的payload。如果注入点不在GET参数里而在POST的JSON字段中可以这样指定fenjing --url http://127.0.0.1:5000/submit --method POST --data {name: fenjing}这里有一个容易踩的坑--data参数的内容格式取决于目标接口是表单还是JSON。表单格式是namefenjingJSON格式是{name: fenjing}。如果填错工具发的请求目标解不了自然测不出注入点。我在刚开始用的时候就在这里卡了十几分钟一直以为是工具坏了后来抓包才发现是数据格式不对。3. 实战从靶场环境到自动化注入全过程3.1 搭建一个可测试的靶场为了演示我建议你在本地起一个Flask应用作为靶场。下面这个是我测试时用的最小demo注意这只是用于授权的安全研究千万别把它开在公网。from flask import Flask, request, render_template_string import os app Flask(__name__) app.route(/) def index(): name request.args.get(name, world) template f h1Hello, {name}!/h1 return render_template_string(template) app.route(/read) def read(): filename request.args.get(file, test.txt) try: with open(filename, r) as f: return f.read() except Exception as e: return str(e) app.route(/exec) def exec_cmd(): cmd request.args.get(cmd, id) return os.popen(cmd).read() if __name__ __main__: app.run(host127.0.0.1, port5000, debugTrue)这个demo里有三个接口/是注入点用来测试SSTI/read和/exec是模拟“注入成功后能做什么”的辅助接口。当然实际利用时并不需要你预先留后门这里只是为了在实验环境里验证工具执行结果。启动后先手工确认注入存在curl http://127.0.0.1:5000/?name{{7*7}}如果能返回49说明Jinja2注入点正常。接下来就可以用fenjing自动探测了。3.2 配置fenjing完成注入演示先用最简单的方式跑一下fenjing --url http://127.0.0.1:5000/?namefenjingfenjing会自动访问这个URL尝试多种引擎的检测样本。如果检测到Jinja2它会继续探测过滤规则。因为demo没做任何过滤所以很快就能生成一个最精简的payload。输出大概长这样[] 检测到模板引擎: Jinja2 [] 未发现过滤规则 [] 可用payload: {{7*7}}但真实目标不会这么仁慈。我们给demo加上一个过滤函数把点号、下划线、globals这些常见关键字全部替换掉import re def waf(name): name re.sub(r\., , name) name re.sub(r_, , name) name re.sub(r\[|\], , name) name re.sub(r__globals__|__class__|__mro__|__subclasses__|__builtins__|__import__, , name) return name然后修改/接口在进入模板前执行name waf(name)。这时再手工访问{{__class__.__mro__[2].__subclasses__()}}就会被简单粗暴地剥掉大半内容。再次执行fenjingfenjing --url http://127.0.0.1:5000/?namefenjing这次它会先探测到过滤规则然后开始枚举可用的绕过组合。在我的测试机上耗时大概十几秒左右工具给出了类似这样的payload{{cycler.__init__.__globals__.os.popen(id).read()}}但因为有下划线、点号过滤工具会把payload里的关键符号替换成\x5f编码形式、|attr过滤器、getitem调用等最终生成的请求类似{{()|attr(\x5f\x5fclass\x5f\x5f)|attr(\x5f\x5fbase\x5f\x5f)|attr(\x5f\x5fsubclasses\x5f\x5f)()}}这只是其中一个形态实际生成的payload可能还会包裹大量的字符串拼接和编码变换看起来像乱码但确实能绕过过滤。这就是工具的价值你只需要告诉它目标地址它把最耗时的绕过过程跑完。3.3 看懂输出结果与生成的Payload第一次用fenjing的人看到一串编码后的payload往往会一脸懵。我建议你不要把它当黑盒直接用而是学会拆解。工具生成的payload本质上是把原始Python对象链中的每个敏感部分替换成等价的“非敏感”表达。这里我列几个典型的等价替换关系原始用法被过滤后的替代表达原理__class__\x5f\x5fclass\x5f\x5f十六进制编码绕过关键字过滤.__class__attr(class)obj[key]obj.__getitem__(key)用方法调用替代中括号下标[0](1-1)或(True-False)或用pop(0)用算术逻辑生成数字空格%09或注释符#用Tab、注释符绕过空格过滤os字符串(osstring) 等比如原始链是().__class__.__base__.__subclasses__()被点号过滤后可能变成()|attr(__class__)|attr(__base__)|attr(__subclasses__)()如果再过滤下划线就用编码()|attr(\x5f\x5fclass\x5f\x5f)|attr(\x5f\x5fbase\x5f\x5f)|attr(\x5f\x5fsubclasses\x5f\x5f)()看起来很长核心逻辑没变。理解这个替换逻辑你在手工调试时就不会被工具输出吓到。4. 手工绕过技巧与fenjing的底层逻辑对照4.1 绕过空格和关键词过滤虽然fenjing能自动生成payload但了解手工绕过的原理能帮你判断工具的输出是否合理也能在工具失效时切换思路。先说空格绕过。很多WAF或应用层过滤会把空格替换为空导致{{7*7}}变成{{7*7}}没问题但像{{config}}这种不需要空格的也还好真正痛苦的是长链里的空格比如{{ .__class__.__mro__[2] }}这种。绕过的常用姿势包括使用Tab符%09替换空格使用Jinja2的注释语法{# #}填充例如{{{#a#}|attr(__class__)}}因为注释会被解析器忽略但能起到占位作用使用\n换行符替代空格关键词过滤的绕过就更讲究了。__class__这类关键字被ban首选是|attr()过滤器配合字符串拼接或编码。例如{{.__class__}}被过滤后可以写成{{|attr(__class__)}}如果连__class__字符串本身也过滤了就把字符串拆开{{|attr(__class__)}}或者用十六进制{{|attr(\x5f\x5fclass\x5f\x5f)}}Jinja2解析时会先把\x5f\x5fclass\x5f\x5f还原成__class__然后再传给attr过滤器。这种编码层和解析层的分离正是绕过过滤的核心空间。4.2 绕过数字和字母限制过滤字母是SSTI里最让人头疼的一类。有的场景干脆把字母全ban了只允许数字和符号。网上常见的思路是用Jinja2内置的请求对象或配置对象来“拼”出字母因为config、self、request这些对象本身含有英文属性名。比如{{config.__str__()}}等于把config转成字符串可以从里面截取字母。不过这种方式可靠性有限依赖配置内容。另一个思路是使用chr()函数。在Jinja2里可以通过__builtins__拿到chr再逐个字符拼接出命令。如果字母被过滤到连chr都不给就考虑用已有的字母作为“种子”比如.__class__里就包含class这些字母通过[索引]取出再做拼接。fenjing在处理这类问题时采用了一种更聪明的做法它不追求构造出任意字符串而是利用Jinja2内置的过滤器名、全局函数名、请求参数名等固有名称作为“字母池”。从这些已知字符串中按索引取字符再拼接出需要的关键字。这种思路比硬编码所有字母更稳定因为它不需要可用的chr。举个例子如果环境里有request对象工具可能构造出类似这样的片段request|attr(\x5f\x5fglobals\x5f\x5f)|attr(\x5f\x5fgetitem\x5f\x5f)(os)|attr(popen)(id)|attr(read)()这里request就是现成的对象\x5f编码绕过了下划线getitem和popen是从request的灵魂属性里取出来的。整个过程看起来复杂但每一步都是“用已有资源换等价表现”。4.3 编码与拼接的底层思路手工绕过和自动化工具相通的地方在于都遵循一套变换规则。我总结下来就三条第一任何敏感字符串都可以通过编码、拼接、取子串、反转等方式变换成非敏感形式。十六进制、unicode、Base64都是常用的编码手段但要注意目标环境中这些编码是否会被二次解码。第二任何敏感操作符都可以通过等价API替代。点号对应attr过滤器或getattr函数中括号对应__getitem__或pop双下划线包裹的属性名只是字符串取决于获取方式。第三任何敏感对象都可以通过已有链一步步索引出来。从()、、config、request这些“起点”出发沿着__class__、__mro__、__globals__等属性爬最终找到os、subprocess等危险模块。fenjing的优化算法本质上是把这三条规则模板化。它会根据探测到的过滤条件选择合适的规则组合再用请求响应做验证最终收敛到一组可用payload。理解了这套底层逻辑你就明白为什么有时候fenjing生成的payload看起来“多此一举”——因为它需要确保在当前过滤规则下100%可用而不是看起来最简洁。5. 常见问题与排查技巧实录5.1 工具探测不到引擎怎么办我遇到过的第一种情况是fenjing提示未检测到任何模板引擎。这里优先检查几个点目标是否真的存在SSTI。手工先试{{7*7}}如果不返回49说明注入点不成立工具再强也没用。注入点的位置有没有写对。--url里的参数如果没被模板引擎解析注入点可能在POST的json、form或headers里要对应调整参数。目标是否对检测样本做了特殊处理。有些应用会把包含{{的请求直接拒掉这时需要修改fenjing的请求模板让检测值藏在cookie或自定义header中。排查时建议启动一个本地代理抓包比如配合--proxy http://127.0.0.1:8080让Burp记录fenjing发出的原始请求看它用了哪些检测样本、返回了什么内容。这样能快速定位是工具没发对请求还是服务器没按预期响应。5.2 请求超时或触发限流真实目标往往有访问频率限制或WAF拦截。fenjing探测时需要发送大量请求如果触发限流后面所有请求都会被拒绝。这时候我一般做两件事一是降低并发。fenjing默认可能会同时开多个线程过高的并发既容易被封也可能导致结果不稳定。我通常把并发数调低宁可慢一点也不要打到一半被断。二是使用代理或自定义Header。在--headers里加上正常的浏览器User-Agent有的场景还需要加上Referer、X-Forwarded-For等字段。这一步不是必须但对某些有基础防护的环境很有效。另外如果目标服务器响应较慢可以把超时时间调大。工具默认几秒超时慢一点的接口可能直接超时误判为失败。5.3 WAF拦截后生成超长payloadWAF拦截的常见策略是对请求长度做限制或者对过于明显的特征串做正则匹配。fenjing生成的payload为了绕过过滤往往会变得很长。一旦payload长度超过服务器或WAF的限制就会返回414 URI Too Long或直接丢弃。我总结了一张排查表方便按症状快速定位现象可能原因处理思路返回414payload过长使用POST方式提交把注入点放到请求体里绕过URL长度限制返回403WAF拦截特征增加编码层数或使用分段请求、分块传输等方式绕过返回500payload语法错误检查引擎类型是否判断正确Jinja2和Twig语法差异大返回200但无回显注入点存在但输出被过滤使用外带数据方法如DNS请求、HTTP回调工具卡死在探测阶段过滤规则太多导致组合爆炸手动缩小payload模块范围或先手动确认多个过滤特征再交给工具WAF拦截这类问题我个人的经验是不要完全依赖工具去盲撞。先用Burp手工发送几个测试请求观察WAF到底是匹配了哪个特征。比如它可能只是拦截了os.popen那payload换一种方式调用subprocess.Popen就绕过去了。工具能做到的是在已有规则下优化组合但如果规则本身过于复杂它就很难快速收敛。6. 安全加固与防御建议6.1 从开发侧避免SSTI讲完了工具使用反而更要强调一句SSTI是可以通过正确开发习惯完全避免的。最有效的手段就是把“模板解析”和“用户输入”彻底隔离。具体来说不要使用render_template_string拼接用户输入。如果用Flask尽量用render_template加载静态模板文件把用户输入作为参数传入模板由模板内部的{{ }}变量渲染而不是拼到模板源字符串里。对模板引擎做沙箱隔离。Jinja2可以用sandboxedEnvironment限制访问危险属性和内置函数但注意沙箱并非万无一失不能作为唯一防线。输入校验必须在服务端执行。常见的过滤黑名单只是缓解措施很容易被编码绕过。更可靠的是白名单校验限定用户输入只能是字母、数字、下划线长度限制在合理范围内。保持框架和依赖更新。大部分主流框架对SSTI都有官方修复或加固方案升级到最新版本能有效降低可利用性。如果你在做代码审计看到一个接口把request.args的值直接丢进render_template_string不管前面有多少层过滤都应该标记为高风险。6.2 检测与应急响应思路对于防御方检测SSTI不能只靠扫描器。更实际的方式是在运行时监控模板渲染的入参特征。比如在WAF层面对包含{{、{%、#{等模板标记的请求做重点记录和告警。对__class__、__globals__、os.system、subprocess等敏感关键字做统一的风险评分。对回显内容中出现49、7777等固定探测值的请求进行关联分析确认是否存在批量扫描。应急响应时如果确认SSTI被利用第一步是下线目标应用保留请求日志。接下来通过日志还原攻击者的完整payload分析其利用链判断是否读到了环境变量或配置文件。如果payload中有命令执行还要进一步排查服务器的网络连接和进程启动记录确认是否有持久化行为。整个过程需要冷静和条理千万不要急着把应用重启了事日志一旦丢掉就很难溯源。我个人的体会是SSTI的自动化工具只是把“绕过过滤”这层体力活加速了。真正决定一次测试或一次防御成败的还是对模板引擎内部机制的理解。就像一个老话说的工具能帮你跑一百步但第一步和最后一步永远得自己迈。你可以在拿到fenjing生成的payload后把它和手工构造的链子对照着看多琢磨几遍慢慢地那些编码、拼接、充数的花招就会变成你自己的直觉。
返回列表