Lamber's Blog

记一道典型的flask SSTI题目

字数统计: 3.3k阅读时长: 14 min
2021/08/30

一、概念了解及环境搭建

1. 一些新概念

  1. PyCharm是一种Python IDE,带有一整套可以帮助用户在使用Python语言开发时提高其效率的工具,比如调试、语法高亮、Project管理、代码跳转、智能提示、自动完成、单元测试、版本控制。此外,该IDE提供了一些高级功能,以用于支持Django框架下的专业Web开发。
  2. IDE: 集成开发环境(Integrated Development Environment)是用于提供程序开发环境的应用程序。简单来说就是提高程序员开发效率的东西。
  3. web服务器, 应用服务器:处理逻辑的服务器,处理php,python,不能直接通过nginx这种web服务器来处理。eg, uwsgi处理Python;
  4. 常见web应用框架: flask, django

2. 环境搭建

工具准备:python3 & PyCharm

1.flask搭建过程

1.安装python3虚拟环境

1
2
3
python3 -m venv venv  --> 安装虚拟环境(第二个venv指虚拟环境的名字)
venv\Scripts\activate --> 启动虚拟环境
deactivate --> 关闭虚拟环境

安装虚拟环境主要是为了不让flask测试框架所安装的一些python模块, 影响自己本机的python环境。

1
2
3
4
pip install Flask                   -->  pip版本过低
python -m pip install --upgrade pip --> 更新pip版本
pip install Flask --> 成功安装Flask
pip list --> python所安装模块查询版本成功√

2.PyCharm使用:
File –> New Project 创建一个新项目
File –> Settings –> Project:Project Interpreter 设置python路径, 然后选择pip然后可以安装python相应模块

参考文章:

  1. 《pycharm使用教程看这篇就足够了》 https://www.jianshu.com/p/2a4d388b86e9

二、flask框架学习

一、用flask写一个hello world:

1
2
3
4
5
6
7
8
9
10
from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello_world():
return 'Hello World'

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

  1. 导入Flask
  2. 创建一个该类的实例
  3. route()装饰器来告诉Flask触发函数的URL
  4. 函数名称被用来生成相关联的URL
  5. 最后返回需要在用户浏览器中显示的信息

二、让这个 “hello world” 跑起来的方法

  1. powershell

    1
    2
    $env:FLASK_APP="flaskr"    -->  flaskr指项目名称
    flask run
  2. cmd

    1
    2
    3
    set FLASK_APP=flaskr       -->  flaskr指项目名称
    set FLASK_ENV=development
    flask run
  3. linux

    1
    2
    $ export FLASK_APP=flaskr  -->  flaskr指项目名称
    $ flask run

三、如果使用PyCharm:
如果是使用PyCharm来运行flask的话, 就会很简单。代码写好之后直接右键选择run, 就可以运行了

参考文章:

  1. 参考教程w3cschool https://www.w3cschool.cn/flask/flask_overview.html
  2. flask框架参考flask中文文档 https://dormousehole.readthedocs.io/en/latest/
  3. if __name__ == '__main__'的理解参考《如何简单地理解python中的if __name__ == '__main__'》https://blog.csdn.net/yjk13703623757/article/details/77918633/

三、解题过程

题目源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import random
from flask import Flask, render_template_string, render_template, request
import os

app = Flask(__name__)
app.config['SECRET_KEY'] = 'evoa@Syclover | https://evoa.me 233333'

def encode(line, key, key2):
return ''.join(chr(x ^ ord(line[x]) ^ ord(key[::-1][x]) ^ ord(key2[x])) for x in range(len(line)))

file = open("/app/flag", "r")
flag = file.read()

app.config['flag'] = encode(flag,'GQIS5EmzfZA1Ci8NslaoMxPXqrvFB7hYOkbg9y20W3','xwdFqMck1vA0pl7B8WO3DrG
Lma4sZ2Y6ouCPEHSQVT')
flag = ""

os.remove("/app/flag")
nicknames = ['˜”*°★☆★_%s_★☆★°°*', '%s ~♡ⓛⓞⓥⓔ♡~', '%s Вêчңø в øĤлâйĤé',
'♪ ♪ ♪ %s ♪ ♪ ♪ ', '[♥♥♥%s♥♥♥]', '%s, kOтO®Aя )(оТеЛ@ ©4@$tьЯ', '♔%s♔',
'[♂+♂=♥]%s[♂+♂=♥]']

@app.route('/', methods=['GET', 'POST'])
def index():
if request.method == 'POST':
try:
p = request.values.get('nickname')
id = random.randint(0, len(nicknames) - 1)
if p != None:
if '.' in p or '_' in p or '\'' in p:
return 'Your nickname contains restricted characters!'
return render_template_string(nicknames[id] % p)
except Exception as e:
print(e)
return 'Exception'

return render_template('index.html')

@app.route('/syc')
def syc():
if "." in request.args.get('name') or "'" in request.args.get('name') or '"' in request.args.get('name'):
return "nonono"
else:
return render_template_string(request.args.get('name'))

if __name__ == '__main__':
app.run(host='0.0.0.0', port=1337)

分析题目源码
该题目定义了两个路由

  1. @app.route('/', methods=['GET', 'POST'])
  2. @app.route('/syc')

1. 配置里的flag

路由 @app.route('/', methods=['GET', 'POST']) 作用为:
如果是POST请求,则将用户输入(nickname)赋值给参数p。否则直接返回index.html
如果参数p中(即nickname中)含有 ., _, \' (点号, 下划线, 单引号)则返回值: Your nickname contains restricted characters!
如果参数p中不包含非法字符, 则在数组nicknames中随机取出一个元素, 将用户输入(nickname)格式化输出。并且在输出时受到 render_template_string() 函数的影响

对于 render_template_string() 函数
查阅flask官方文档可知, 在Flask中, Jinja2默认配置如下

  • 当使用 render_template() 时,扩展名为 .html 、 .htm 、 .xml 和 .xhtml 的模板中开启自动转义。
  • 当使用 render_template_string() 时,字符串开启 自动转义。
  • 在模板中可以使用{% autoescape %}来手动设置是否转义。
  • Flask 在 Jinja2 环境中加入一些全局函数和辅助对象,以增强模板的功能。

也就是说, render_template_string() 函数会将字符串以Jinja2模板的渲染规则进行转义。

查阅Jinja2官方文档可知, 在Jinja2中, 默认定界符(delimiters)有如下几种:

简单来说:

  1. {% ... %} 是用来控制程序流程的定界符, 例如条件语句, 循环语句。使用语法与python类似, 但要注意在语法结束时要添加 {% endif %}, {% endfor %}…。一个简单的利用 for 循环, 遍历 username 的示例如下

    1
    2
    3
    4
    5
    6
    <h1>Members</h1>
    <ul>
    {% for user in users %}
    <li>{{ user.username|e }}</li>
    {% endfor %}
    </ul>
  2. {{ ... }} 是允许使用基本表达式的定界符, 并将结果输出
    例如: {{ 7*7 }} 的返回值就是 49

  3. {# ... #} 用于注释一整块语句, 注释用法如下:

    1
    2
    3
    4
    5
    {# note: commented-out template because we no longer use this
    {% for user in users %}
    ...
    {% endfor %}
    #}
  4. # ... ## 用于注释一行语句, 注释用法如下:

    1
    2
    3
    # for item in seq:
    <li>{{ item }}</li> ## this comment is ignored
    # endfor

参考flask模板 https://dormousehole.readthedocs.io/en/latest/templating.html?highlight=render_template_string#id2

缺省情况下,以下全局变量可以在 Jinja2 模板中使用:
config: 当前配置对象
request: 当前请求对象, 在没有活动请求环境情况下渲染模板时,这个变量不可用。
session: 当前会话对象, 在没有活动请求环境情况下渲染模板时,这个变量不可用。
g: 请求绑定的全局变量, 在没有活动请求环境情况下渲染模板时,这个变量不可用。

既然config是全局变量, 而 {{ ... }} 可以将基本表达式的结果输出, 并且 render_template_string() 会将字符串以Jinja2的规则转义。那是否可以利用 render_template_string() 先将 "{{ ... }}" 字符串转义成Jinja2模板格式, 而转义之后就成了 {{ ... }} 定界符, 再利用这个定界符输出 config 这个全局变量呢。
于是我搭建了一个测试环境, 代码跟输出结果如下:

1
2
3
4
5
6
7
8
from flask import Flask, render_template_string
app = Flask(__name__)
@app.route('/')
def index():
return render_template_string("{{ config }}")

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

很好, 可以看到成功读取到了 config 全局变量。
感觉到第一个flag就在眼前!
访问题目, 输入 {{ config }}

是flag!
不过有点遗憾的是, flag是加了密的。回到题目中, 再仔细看看代码。找到加密flag的部分:

1
2
3
4
5
6
7
8
9
10
def encode(line, key, key2):
return ''.join(chr(x ^ ord(line[x]) ^ ord(key[::-1][x]) ^ ord(key2[x])) for x in range(len(line)))

file = open("/app/flag", "r")
flag = file.read()

app.config['flag'] = encode(flag,'GQIS5EmzfZA1Ci8NslaoMxPXqrvFB7hYOkbg9y20W3','xwdFqMck1vA0pl7B8WO3DrGLma4sZ2Y6ouCPEHSQVT')
flag = ""

os.remove("/app/flag")

观察 encode() 函数, 发现返回值是由 x, ord(line[x]), ord(key[::-1][x]), ord(key2[x]) 四部分亦或得到。
亦或参考文章
1):https://www.cnblogs.com/tmdsleep/p/9933647.html
2):http://www.elecfans.com/d/653831.html

通过学习异或运算可知:

如果a、b两个值不相同,则异或结果为1。 如果a、b两个值相同,异或结果为0。 异或也叫半加运算,其运算法则相当于不带进位的二进制加法:二进制下用1表示真,0表示假,则异或的运算法则为:0⊕0=0,1⊕0=1,0⊕1=1,1⊕1=0(同为0,异为1),这些法则与加法是相同的,只是不带进位,所以异或常被认作不进位加法。

而关于异或运算, 有一个推论
e = a ^ b ^ c ^ d 与
b = a ^ c ^ d ^ e 相等效

又查询资料可知
ord()是将字符转换成ASCII;
chr()是将ASCII转换成字符
在加密flag的时候, 是将flag作为参数line传入。也就是说 for x in range(len(line)) 的作用是获取flag中每一个字符的坐标, ord(line[x]) 就是将flag中每一个字符转换成ASCII。

即加密flag部分, 可以简单理解为:
密文 = x ^ flag ^ ord(key[::-1][x]) ^ ord(key2[x])

根据推论解密flag, 即:
flag = x ^ 密文 ^ ord(key[::-1][x]) ^ ord(key2[x])

解密脚本:

1
2
3
4
5
6
7
8
9
10
def encode(encrypt_flag, key, key2):
return ''.join(chr(x ^ ord(encrypt_flag[x]) ^ ord(key[::-1][x]) ^ ord(key2[x])) for x in range(len(encrypt_flag)))

encrypt_flag = "\x18X5\x0cx\x19k}\r\x01a\x0c\x1fT\x16X?hl\x1e\\OLL(n\x17\x0fi]B\x0fh8"
key = "GQIS5EmzfZA1Ci8NslaoMxPXqrvFB7hYOkbg9y20W3"
key2 = "xwdFqMck1vA0pl7B8WO3DrGLma4sZ2Y6ouCPEHSQVT"

print(decode(encrypt_flag, key, key2))

# Syc{this_1s_Twice_interv1ew_F14g}

解码得到第一个flag:
Syc{this_1s_Twice_interv1ew_F14g}

其实仔细看一下, 构造的解密脚本其实和原加密脚本一样。

2. 根目录下的flag

任务中说, 第二个flag在根目录下面, 于是想到利用模板注入漏洞达到命令执行的效果, 从而读取到根目录的文件。

查阅跟模板注入的有关文档可知

  1. https://xz.aliyun.com/t/3679
  2. https://www.kingkk.com/2018/06/Flask-Jinja2-SSTI-python-%E6%B2%99%E7%AE%B1%E9%80%83%E9%80%B8/

想要利用模板注入来达到命令执行的效果, 需要调用python的基类
首先利用 __class__ 查询全局变量 config 的参数类型, 即 {{ config.__class__ }}。执行查询, 结果如下。

感到疑惑, 再回到题目源码。看到在 @app.route('/', methods=['GET', 'POST']) 路由里, 过滤了参数 nickname 中的 _.。调用python基类所必须的下划线被过滤了, 所以查询全局变量 config 的类失败了。

既然第一个路由已经把路封上了, 这时候去读第二个路由 @app.route('/syc') 的源码。发现过滤了 ., ', "。点号也被过滤了, 感觉题目已经无解了。这时候想到, 虽然调用基类的下划线是必须的, 绕不过去, 但是是否可以绕过点号的使用。百度查询很久, 没有发现任何绕过点号的方法。然后在群里看到师傅说, 把点号的限制删除了。这时候又可以继续做题了!

分析 @app.route('/syc') 这个路由, 利用 request.args.get('name') 以GET方式传入了一个name参数, 再通过 render_template_string() 函数将name以Jinja2模板的渲染规则进行转义。

先尝试读取全局变量: http://114.116.44.23:58470/syc?name={{ config }}, 成功得到config的值。

这时候发现, 对于配置里的flag, 不仅可以通过第一个路由读取, 而且第二个路由也可以读取到这个配置里的flag。

继续读取根目录flag之路:
再次尝试读取全局变量 config 的参数类型, http://114.116.44.23:58470/syc?name={{ config.__class__ }} 得到返回结果 <class 'flask.config.Config'>

然后利用 __init__ 初始化类
再加 __globals__ 全局查询所有的方法及变量及参数
http://114.116.44.23:58470/syc?name={{ config.__class__.__init__.__globals__ }}

全局查询之后, 发现有python3的os模块(在题目中也调用了os模块执行系统命令)。
查询官方文档之后, 得知 os 模块执行系统命令有两种方式 os.system()os.popen()

其中 os.system() 无法获得输出和返回值, 所以这里采取 os.popen() 方法。使用 read() 方法可以看到输出结果。即 os.popen().read()

这时候一个完整的命令执行payload如下:
http://114.116.44.23:58470/syc?name={{ config.__class__.__init__.__globals__['os'].popen('whoami').read() }}

这时候想到, 单引号是被过滤了的, 需要绕过单引号的过滤。查询资料得知, request.args是Flask中的一个属性,为返回请求的参数。我把 module 作为调用模块的变量名, 将调用的os模块传入。而shell 作为执行的命令的变量名, 将要执行的命令传入。
这时候构造的payload如下:
http://114.116.44.23:58470/syc?name={{ config.__class__.__init__.__globals__[request.args.module].popen(request.args.shell).read() }}&module=os&shell=whoami

尝试执行, 得到结果如下:

命令能够成功执行了, 接下来就是拿flag了

列下目录:

很好, 看到flag了。

…居然是假flag, 再看一下题目描述, flag是在服务器根目录。再去根目录下读一下flag。

拿到真flag了
Syc{this_2s_Twice_interv1ew_F14g}

CATALOG
  1. 1. 一、概念了解及环境搭建
    1. 1.1. 1. 一些新概念
    2. 1.2. 2. 环境搭建
  2. 2. 二、flask框架学习
  3. 3. 三、解题过程
    1. 3.1. 1. 配置里的flag
    2. 3.2. 2. 根目录下的flag